add: login page in temp_frontend, improved UI
This commit is contained in:
parent
35749c65a3
commit
bf159dcbd3
8
bun.lock
8
bun.lock
@ -91,7 +91,7 @@
|
||||
"@elysiajs/server-timing": "^1.4.0",
|
||||
"@rabbit-company/argon2id": "^2.1.0",
|
||||
"chalk": "^5.6.2",
|
||||
"elysia": "^1.4.15",
|
||||
"elysia": "catalog:",
|
||||
"elysia-ip": "^1.0.10",
|
||||
"zod": "^4.1.12",
|
||||
},
|
||||
@ -217,6 +217,7 @@
|
||||
"sonner": "^2.0.7",
|
||||
"swr": "^2.3.6",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"zod": "^4.1.12",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@react-router/dev": "^7.7.1",
|
||||
@ -233,6 +234,9 @@
|
||||
},
|
||||
},
|
||||
},
|
||||
"catalog": {
|
||||
"elysia": "^1.4.15",
|
||||
},
|
||||
"packages": {
|
||||
"@alicloud/credentials": ["@alicloud/credentials@2.4.4", "", { "dependencies": { "@alicloud/tea-typescript": "^1.8.0", "httpx": "^2.3.3", "ini": "^1.3.5", "kitx": "^2.0.0" } }, "sha512-/eRAGSKcniLIFQ1UCpDhB/IrHUZisQ1sc65ws/c2avxUMpXwH1rWAohb76SVAUJhiF4mwvLzLJM1Mn1XL4Xe/Q=="],
|
||||
|
||||
@ -2970,6 +2974,8 @@
|
||||
|
||||
"@cloudflare/kv-asset-handler/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
|
||||
|
||||
"@cvsa/cvsa-temp/zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="],
|
||||
|
||||
"@deno/shim-deno/which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
|
||||
|
||||
25
package.json
25
package.json
@ -3,16 +3,21 @@
|
||||
"version": "3.15.34",
|
||||
"private": false,
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
"packages/frontend",
|
||||
"packages/core",
|
||||
"packages/backend",
|
||||
"packages/crawler",
|
||||
"packages/elysia",
|
||||
"packages/temp_frontend",
|
||||
"packages/solid",
|
||||
"packages/palette"
|
||||
],
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
"packages/frontend",
|
||||
"packages/core",
|
||||
"packages/backend",
|
||||
"packages/crawler",
|
||||
"packages/elysia",
|
||||
"packages/temp_frontend",
|
||||
"packages/solid",
|
||||
"packages/palette"
|
||||
],
|
||||
"catalog": {
|
||||
"elysia": "^1.4.15"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"arg": "^5.0.2",
|
||||
"dotenv": "^17.2.3",
|
||||
|
||||
@ -14,7 +14,7 @@
|
||||
"@elysiajs/server-timing": "^1.4.0",
|
||||
"@rabbit-company/argon2id": "^2.1.0",
|
||||
"chalk": "^5.6.2",
|
||||
"elysia": "^1.4.15",
|
||||
"elysia": "catalog:",
|
||||
"elysia-ip": "^1.0.10",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
|
||||
@ -32,7 +32,8 @@ export const loginHandler = new Elysia({ prefix: "/auth" })
|
||||
username: user.username,
|
||||
nickname: user.nickname,
|
||||
role: user.role
|
||||
}
|
||||
},
|
||||
sessionID: sessionId
|
||||
};
|
||||
},
|
||||
{
|
||||
@ -44,7 +45,8 @@ export const loginHandler = new Elysia({ prefix: "/auth" })
|
||||
username: t.String(),
|
||||
nickname: t.Optional(t.String()),
|
||||
role: t.String()
|
||||
})
|
||||
}),
|
||||
sessionID: t.String()
|
||||
}),
|
||||
401: t.Object({
|
||||
message: t.String()
|
||||
|
||||
@ -105,6 +105,7 @@ const BiliVideoDataSchema = BiliVideoSchema.extend({
|
||||
export const searchHandler = new Elysia({ prefix: "/search" }).get(
|
||||
"/result",
|
||||
async ({ query }) => {
|
||||
const start = performance.now();
|
||||
const searchQuery = query.query;
|
||||
const [songResults, videoResults] = await Promise.all([
|
||||
getSongSearchResult(searchQuery),
|
||||
@ -112,24 +113,32 @@ export const searchHandler = new Elysia({ prefix: "/search" }).get(
|
||||
]);
|
||||
|
||||
const combinedResults = [...songResults, ...videoResults];
|
||||
return combinedResults.sort((a, b) => b.rank - a.rank);
|
||||
const data = combinedResults.sort((a, b) => b.rank - a.rank);
|
||||
const end = performance.now();
|
||||
return {
|
||||
data,
|
||||
elapsedMs: end - start
|
||||
};
|
||||
},
|
||||
{
|
||||
response: {
|
||||
200: z.array(
|
||||
z.union([
|
||||
z.object({
|
||||
type: z.literal("song"),
|
||||
data: SongSchema,
|
||||
rank: z.number()
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("bili-video"),
|
||||
data: BiliVideoDataSchema,
|
||||
rank: z.number()
|
||||
})
|
||||
])
|
||||
),
|
||||
200: z.object({
|
||||
elapsedMs: z.number(),
|
||||
data: z.array(
|
||||
z.union([
|
||||
z.object({
|
||||
type: z.literal("song"),
|
||||
data: SongSchema,
|
||||
rank: z.number()
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("bili-video"),
|
||||
data: BiliVideoDataSchema,
|
||||
rank: z.number()
|
||||
})
|
||||
])
|
||||
)
|
||||
}),
|
||||
404: z.object({
|
||||
message: z.string()
|
||||
})
|
||||
|
||||
@ -98,9 +98,13 @@ const songInfoGetHandler = new Elysia({ prefix: "/song" }).get(
|
||||
cover: t.Optional(t.String())
|
||||
}),
|
||||
404: t.Object({
|
||||
code: t.String(),
|
||||
message: t.String()
|
||||
})
|
||||
},
|
||||
headers: t.Object({
|
||||
"Authorization": t.Optional(t.String())
|
||||
}),
|
||||
detail: {
|
||||
summary: "Get information of a song",
|
||||
description:
|
||||
|
||||
@ -5,7 +5,8 @@ export function Error({ error }: { error: { status: number; value: { message?: s
|
||||
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="max-w-md w-full mx-4 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>
|
||||
|
||||
@ -1,12 +1,18 @@
|
||||
import { toast } from "sonner";
|
||||
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>
|
||||
<main className="w-full max-sm:mx-3 pt-14 sm:w-xl xl:w-2xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<a href="/">
|
||||
<h1 className="text-3xl mb-5">中V档案馆</h1>
|
||||
</a>
|
||||
<div className="h-8">
|
||||
<LoginOrLogout />
|
||||
</div>
|
||||
</div>
|
||||
<Search />
|
||||
{children}
|
||||
</main>
|
||||
@ -14,13 +20,37 @@ export function Layout({ children }: { children?: React.ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function LayoutWithouSearch({ children }: { children?: React.ReactNode }) {
|
||||
const LoginOrLogout = () => {
|
||||
const session = localStorage.getItem("sessionID");
|
||||
if (session) {
|
||||
return (
|
||||
<span
|
||||
onClick={() => {
|
||||
localStorage.removeItem("sessionID");
|
||||
toast.success("已退出登录");
|
||||
}}
|
||||
>
|
||||
退出登录
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
return <a href="/login">登录</a>;
|
||||
}
|
||||
};
|
||||
|
||||
export function LayoutWithoutSearch({ 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>
|
||||
<main className="w-full max-sm:mx-3 pt-14 sm:w-xl xl:w-2xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<a href="/">
|
||||
<h1 className="text-3xl mb-5">中V档案馆</h1>
|
||||
</a>
|
||||
<div className="h-8">
|
||||
<LoginOrLogout />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@ -6,7 +6,7 @@ interface SearchResultsProps {
|
||||
query: string;
|
||||
}
|
||||
|
||||
const formatDateTime = (date: Date): string => {
|
||||
export 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");
|
||||
@ -20,7 +20,7 @@ const formatDateTime = (date: Date): string => {
|
||||
const biliIDSchema = z.union([z.string().regex(/BV1[0-9A-Za-z]{9}/), z.string().regex(/av[0-9]+/)]);
|
||||
|
||||
export function SearchResults({ results, query }: SearchResultsProps) {
|
||||
if (!results || results.length === 0) {
|
||||
if (!results || results.data.length === 0) {
|
||||
if (!biliIDSchema.safeParse(query).success) {
|
||||
return (
|
||||
<div className="text-center pt-6">
|
||||
@ -43,10 +43,12 @@ export function SearchResults({ results, query }: SearchResultsProps) {
|
||||
|
||||
return (
|
||||
<div className="space-y-4 mb-20 mt-5">
|
||||
{results.map((result, index) => (
|
||||
<p>找到 {results.data.length} 个结果({(results.elapsedMs / 1000).toFixed(3)}秒):</p>
|
||||
{results.data.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"
|
||||
className="bg-white dark:bg-neutral-800 rounded-lg shadow-sm border
|
||||
border-gray-200 dark:border-neutral-700 p-2 sm:p-4 hover:shadow-md transition-shadow"
|
||||
>
|
||||
{result.type === "song" ? <SongResult result={result} /> : <BiliVideoResult result={result} />}
|
||||
</div>
|
||||
@ -55,22 +57,22 @@ export function SearchResults({ results, query }: SearchResultsProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function SongResult({ result }: { result: Exclude<SearchResult, null>[number] }) {
|
||||
function SongResult({ result }: { result: Exclude<SearchResult, null>["data"][number] }) {
|
||||
if (result.type !== "song") return null;
|
||||
|
||||
const { data } = result;
|
||||
return (
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
{data.image && (
|
||||
<img
|
||||
src={data.image}
|
||||
alt="歌曲封面"
|
||||
className="w-42 h-24 rounded-sm object-cover flex-shrink-0"
|
||||
className="h-21 w-36 sm:w-42 sm: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>
|
||||
<div className="flex-col">
|
||||
<h3 className="text-lg font-medium line-clamp-1 text-wrap">{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 && (
|
||||
@ -95,7 +97,7 @@ function SongResult({ result }: { result: Exclude<SearchResult, null>[number] })
|
||||
);
|
||||
}
|
||||
|
||||
function BiliVideoResult({ result }: { result: Exclude<SearchResult, null>[number] }) {
|
||||
function BiliVideoResult({ result }: { result: Exclude<SearchResult, null>["data"][number] }) {
|
||||
if (result.type !== "bili-video") return null;
|
||||
|
||||
const { data } = result;
|
||||
@ -116,7 +118,7 @@ function BiliVideoResult({ result }: { result: Exclude<SearchResult, null>[numbe
|
||||
{data.description}
|
||||
</pre>
|
||||
)}
|
||||
<div className="flex items-center space-x-4 my-2 text-xs text-gray-500 dark:text-gray-500">
|
||||
<div className="grid-cols-2 sm: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")}
|
||||
|
||||
@ -5,4 +5,5 @@ export default [
|
||||
route("song/:id/info", "routes/song/[id]/info.tsx"),
|
||||
route("chart-demo", "routes/chartDemo.tsx"),
|
||||
route("search", "routes/search/index.tsx"),
|
||||
route("login", "routes/login.tsx"),
|
||||
] satisfies RouteConfig;
|
||||
|
||||
99
packages/temp_frontend/app/routes/login.tsx
Normal file
99
packages/temp_frontend/app/routes/login.tsx
Normal file
@ -0,0 +1,99 @@
|
||||
import { LayoutWithoutSearch } from "@/components/Layout";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { treaty } from "@elysiajs/eden";
|
||||
import type { App } from "@elysia/src";
|
||||
|
||||
// @ts-expect-error anyway...
|
||||
const app = treaty<App>(import.meta.env.VITE_API_URL!);
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
const [formData, setFormData] = useState({
|
||||
username: "",
|
||||
password: "",
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[name]: value,
|
||||
}));
|
||||
setError("");
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const { data, error } = await app.auth.session.post(formData);
|
||||
|
||||
if (data) {
|
||||
localStorage.setItem("sessionID", data.sessionID);
|
||||
navigate("/", { replace: true });
|
||||
} else {
|
||||
setError(error?.value?.message || "登录失败");
|
||||
}
|
||||
} catch (err) {
|
||||
setError("网络错误,请稍后重试");
|
||||
console.error("Login error:", err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<LayoutWithoutSearch>
|
||||
<h2 className="text-2xl font-bold mb-6">登录</h2>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-600 text-red-700 dark:text-red-200 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4 flex flex-col">
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium mb-2">
|
||||
用户名
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
value={formData.username}
|
||||
onChange={handleChange}
|
||||
required
|
||||
placeholder="请输入用户名"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium mb-2">
|
||||
密码
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
required
|
||||
placeholder="请输入密码"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? "登录中..." : "登录"}
|
||||
</Button>
|
||||
</form>
|
||||
</LayoutWithoutSearch>
|
||||
);
|
||||
}
|
||||
@ -7,8 +7,9 @@ import { useSearchParams } from "react-router";
|
||||
import { SearchBox } from "@/components/Search";
|
||||
import { SearchResults } from "@/components/SearchResults";
|
||||
import { Title } from "@/components/Title";
|
||||
import { LayoutWithouSearch } from "@/components/Layout";
|
||||
import { LayoutWithoutSearch } from "@/components/Layout";
|
||||
|
||||
// @ts-expect-error anyway...
|
||||
const app = treaty<App>(import.meta.env.VITE_API_URL!);
|
||||
|
||||
export type SearchResult = Awaited<ReturnType<typeof app.search.result.get>>["data"];
|
||||
@ -57,19 +58,19 @@ export default function SearchResult() {
|
||||
|
||||
if (!searchParams.get("q")) {
|
||||
return (
|
||||
<LayoutWithouSearch>
|
||||
<LayoutWithoutSearch>
|
||||
<Search query={query} setQuery={setQuery} onSearch={handleSearch} />
|
||||
</LayoutWithouSearch>
|
||||
</LayoutWithoutSearch>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data && !error) {
|
||||
return (
|
||||
<LayoutWithouSearch>
|
||||
<LayoutWithoutSearch>
|
||||
<Title title={searchParams.get("q") || "搜索"} />
|
||||
<Search query={query} setQuery={setQuery} onSearch={handleSearch} className="mb-6" />
|
||||
<Skeleton className="w-full h-24 mb-2" />
|
||||
</LayoutWithouSearch>
|
||||
</LayoutWithoutSearch>
|
||||
);
|
||||
}
|
||||
|
||||
@ -78,10 +79,10 @@ export default function SearchResult() {
|
||||
}
|
||||
|
||||
return (
|
||||
<LayoutWithouSearch>
|
||||
<LayoutWithoutSearch>
|
||||
<Title title={searchParams.get("q") || ""} />
|
||||
<Search query={query} setQuery={setQuery} onSearch={handleSearch} className="mb-6" />
|
||||
<SearchResults results={data} query={query} />
|
||||
</LayoutWithouSearch>
|
||||
</LayoutWithoutSearch>
|
||||
);
|
||||
}
|
||||
|
||||
@ -5,13 +5,15 @@ 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 { toast } from "sonner";
|
||||
import { Error } from "@/components/Error";
|
||||
import { Layout } from "@/components/Layout";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { formatDateTime } from "@/components/SearchResults";
|
||||
|
||||
// @ts-expect-error anyway...
|
||||
const app = treaty<App>(import.meta.env.VITE_API_URL!);
|
||||
|
||||
type SongInfo = Awaited<ReturnType<ReturnType<typeof app.song>["info"]["get"]>>["data"];
|
||||
@ -27,14 +29,17 @@ const SnapshotsView = ({ snapshots }: { snapshots: Snapshots | null }) => {
|
||||
if (!snapshots) {
|
||||
return (
|
||||
<>
|
||||
<h2 className="mt-6 text-2xl font-medium mb-4">历史快照</h2>
|
||||
<Skeleton className="w-full h-20 rounded-lg" />
|
||||
{/* <h2 className="mt-6 text-2xl font-medium mb-4">历史快照</h2> */}
|
||||
<Skeleton className="w-full h-5 rounded-lg mt-4" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<h2 className="mt-6 text-2xl font-medium mb-4">历史快照</h2>
|
||||
<div className="mt-4">
|
||||
<p>
|
||||
播放: {snapshots[0].views.toLocaleString()},更新于{formatDateTime(new Date(snapshots[0].createdAt))}
|
||||
</p>
|
||||
{/* <h2 className="mt-6 text-2xl font-medium mb-4">历史快照</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@ -58,7 +63,7 @@ const SnapshotsView = ({ snapshots }: { snapshots: Snapshots | null }) => {
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</table> */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -143,73 +148,75 @@ export default function SongInfo({ loaderData }: Route.ComponentProps) {
|
||||
}
|
||||
|
||||
const formatDuration = (duration: number) => {
|
||||
const minutes = Math.floor(duration / 60);
|
||||
const seconds = duration % 60;
|
||||
return `${minutes}:${seconds}`;
|
||||
return `${Math.floor(duration / 60)}:${(duration % 60).toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const handleSongNameChange = async () => {
|
||||
if (songName.trim() === "") return;
|
||||
|
||||
await app.song({ id: loaderData.id }).info.patch({ name: songName });
|
||||
const { data, error } = await app.song({ id: loaderData.id }).info.patch(
|
||||
{ name: songName },
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("sessionID") || ""}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
setIsDialogOpen(false);
|
||||
// Refresh the data to show the updated name
|
||||
getInfo();
|
||||
if (error || !data) {
|
||||
toast.error(`无法更新:${error.value.message || "未知错误"}`);
|
||||
}
|
||||
setData(data?.updated);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-screen min-h-screen relative left-0 top-0 flex justify-center">
|
||||
<Layout>
|
||||
<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}
|
||||
referrerPolicy="no-referrer"
|
||||
className="w-full aspect-video object-cover rounded-lg mt-6"
|
||||
/>
|
||||
)}
|
||||
<div className="mt-6 flex justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-4xl font-medium" onDoubleClick={() => setIsDialogOpen(true)}>
|
||||
{data!.name ? data!.name : "未知歌曲名"}
|
||||
</h1>
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑歌曲名称</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
value={songName}
|
||||
onChange={(e) => setSongName(e.target.value)}
|
||||
placeholder="请输入歌曲名称"
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setIsDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSongNameChange}>保存</Button>
|
||||
</div>
|
||||
{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">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-4xl font-medium" onDoubleClick={() => setIsDialogOpen(true)}>
|
||||
{data!.name ? data!.name : "未知歌曲名"}
|
||||
</h1>
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑歌曲名称</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
value={songName}
|
||||
onChange={(e) => setSongName(e.target.value)}
|
||||
placeholder="请输入歌曲名称"
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setIsDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSongNameChange}>保存</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
<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>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
<SnapshotsView snapshots={snapshots} />
|
||||
</main>
|
||||
</div>
|
||||
<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>
|
||||
<SnapshotsView snapshots={snapshots} />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
@ -29,7 +29,8 @@
|
||||
"recharts": "^3.2.1",
|
||||
"sonner": "^2.0.7",
|
||||
"swr": "^2.3.6",
|
||||
"tailwind-merge": "^3.3.1"
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@react-router/dev": "^7.7.1",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user