feat(web): library grid + bangumi manual bind

This commit is contained in:
noelorin 2026-09-25 04:51:16 +08:00
parent 61fe323bf8
commit c7c6e30a56
5 changed files with 238 additions and 0 deletions

View File

@ -0,0 +1,157 @@
import type { Locale } from "@app/i18n";
import { t } from "@app/i18n";
import { Button, Input } from "@app/ui";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
import { createTRPCReactClient, trpc } from "~/lib/trpc";
function LibraryInner(props: { locale: Locale }) {
const { locale } = props;
const [filter, setFilter] = useState<"" | "unmatched" | "ok">("");
const [q, setQ] = useState("");
const list = trpc.media.libraryList.useQuery({
limit: 24,
offset: 0,
scrapeStatus: filter || undefined,
q: q || undefined,
});
const progressList = list.data?.rows ?? [];
const [matchId, setMatchId] = useState<string | null>(null);
const [searchQ, setSearchQ] = useState("");
const search = trpc.media.scrapeSearch.useQuery(
{ q: searchQ },
{ enabled: searchQ.length > 1 && matchId !== null },
);
const bind = trpc.media.scrapeBind.useMutation({
onSuccess: () => {
setMatchId(null);
list.refetch();
},
});
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-xl font-semibold">{t(locale, "library.title")}</h1>
<div className="ml-auto flex gap-2">
<Input
className="w-40"
placeholder={t(locale, "library.searchPlaceholder")}
value={q}
onChange={(e) => setQ(e.target.value)}
/>
<select
className="h-9 rounded-md border border-border bg-background px-2 text-sm"
value={filter}
onChange={(e) => setFilter(e.target.value as typeof filter)}
>
<option value="">{t(locale, "library.filterAll")}</option>
<option value="ok">{t(locale, "library.filterOk")}</option>
<option value="unmatched">{t(locale, "library.unmatched")}</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 lg:grid-cols-6">
{progressList.map((item) => (
<div
key={item.id}
className="overflow-hidden rounded-lg border border-border bg-card"
>
<div className="aspect-[3/4] bg-muted flex items-center justify-center text-xs text-muted-foreground">
{item.posterUrl ? (
<img
src={item.posterUrl}
alt=""
className="h-full w-full object-cover"
/>
) : (
t(locale, "library.noPoster")
)}
</div>
<div className="p-2">
<div className="truncate text-sm font-medium" title={item.title}>
{item.title}
</div>
<div className="mt-1 flex gap-1">
<Button
size="sm"
variant="secondary"
onClick={() => {
window.location.href = `/${locale}/watch?id=${item.id}`;
}}
>
{t(locale, "library.play")}
</Button>
{item.scrapeStatus !== "ok" && (
<Button
size="sm"
variant="outline"
onClick={() => setMatchId(item.id)}
>
{t(locale, "library.match")}
</Button>
)}
</div>
</div>
</div>
))}
{progressList.length === 0 && (
<p className="col-span-full text-sm text-muted-foreground">
{t(locale, "library.empty")}
</p>
)}
</div>
{matchId && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-lg rounded-lg border border-border bg-background p-4 space-y-3">
<h2 className="font-medium">{t(locale, "library.matchTitle")}</h2>
<Input
placeholder={t(locale, "library.searchWork")}
value={searchQ}
onChange={(e) => setSearchQ(e.target.value)}
/>
<ul className="max-h-64 divide-y divide-border overflow-auto rounded border border-border">
{(search.data ?? []).map((hit) => (
<li
key={hit.id}
className="flex items-center justify-between p-2 text-sm"
>
<span>{hit.nameCn || hit.name}</span>
<Button
size="sm"
onClick={() =>
bind.mutate({
mediaItemId: matchId,
bangumiId: hit.id,
})
}
>
{t(locale, "library.bind")}
</Button>
</li>
))}
</ul>
<Button variant="outline" onClick={() => setMatchId(null)}>
{t(locale, "library.cancel")}
</Button>
</div>
</div>
)}
</div>
);
}
const client = createTRPCReactClient();
const qc = new QueryClient();
export function LibraryBody(props: { locale: Locale }) {
return (
<QueryClientProvider client={qc}>
<trpc.Provider client={client} queryClient={qc}>
<LibraryInner locale={props.locale} />
</trpc.Provider>
</QueryClientProvider>
);
}

View File

@ -0,0 +1,19 @@
---
import { COOKIE_LANG, isLocale, localePath, resolvePreferredLocale, type Locale } from "@app/i18n";
import Library from "~/views/library.astro";
const raw = Astro.params["locale"];
const normalized = raw === "zh-cn" ? "zh-CN" : raw;
if (!normalized || !isLocale(normalized)) {
const preferred = resolvePreferredLocale(
Astro.cookies.get(COOKIE_LANG)?.value,
Astro.request.headers.get("accept-language"),
);
return Astro.redirect(localePath(preferred, "/library"));
}
const locale: Locale = normalized;
---
<Library locale={locale} />

View File

@ -0,0 +1,40 @@
---
import { alternatePath, type Locale, t } from "@app/i18n";
import { Toaster } from "@app/ui";
import { AppShell } from "~/components/AppShell";
import { LibraryBody } from "~/components/pages/LibraryBody";
import AppLayout from "~/layouts/AppLayout.astro";
export interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const other: Locale = locale === "zh-CN" ? "en" : "zh-CN";
const labels: Record<string, string> = {
"meta.brand": t(locale, "meta.brand"),
"nav.library": t(locale, "nav.library"),
"nav.browse": t(locale, "nav.browse"),
"nav.mounts": t(locale, "nav.mounts"),
"nav.danmaku": t(locale, "nav.danmaku"),
};
---
<AppLayout
title={t(locale, "library.title")}
locale={locale}
nav={[]}
hideSideRail
switchHref={alternatePath(Astro.url.pathname, other)}
switchLabel={t(other, `locale.${other}` as never)}
seo={{
title: `${t(locale, "library.title")} · ${t(locale, "meta.brand")}`,
path: Astro.url.pathname,
robots: ["noindex", "nofollow"],
}}
>
<AppShell locale={locale} active="library" labels={labels}>
<LibraryBody locale={locale} client:load />
</AppShell>
<Toaster client:load />
</AppLayout>

View File

@ -40,6 +40,17 @@ export const en: Record<MessageKey, string> = {
"library.scan": "Scan mounts", "library.scan": "Scan mounts",
"library.unmatched": "Unmatched", "library.unmatched": "Unmatched",
"library.continue": "Continue watching", "library.continue": "Continue watching",
"library.searchPlaceholder": "Search title",
"library.filterAll": "All",
"library.filterOk": "Scraped",
"library.noPoster": "No poster",
"library.play": "Play",
"library.match": "Match",
"library.empty": "No media yet. Scan a mount in the file browser first.",
"library.matchTitle": "Manual Bangumi match",
"library.searchWork": "Search by title",
"library.bind": "Bind",
"library.cancel": "Cancel",
"browse.title": "File browser", "browse.title": "File browser",
"browse.play": "Play", "browse.play": "Play",

View File

@ -41,6 +41,17 @@ export const zhCN = {
"library.scan": "扫描挂载", "library.scan": "扫描挂载",
"library.unmatched": "未匹配", "library.unmatched": "未匹配",
"library.continue": "继续播放", "library.continue": "继续播放",
"library.searchPlaceholder": "搜索标题",
"library.filterAll": "全部",
"library.filterOk": "已刮削",
"library.noPoster": "无封面",
"library.play": "播放",
"library.match": "匹配",
"library.empty": "暂无媒体,请先在文件浏览中扫描挂载。",
"library.matchTitle": "手动匹配 Bangumi",
"library.searchWork": "搜索作品名",
"library.bind": "绑定",
"library.cancel": "取消",
"browse.title": "文件浏览", "browse.title": "文件浏览",
"browse.play": "播放", "browse.play": "播放",