feat(web): library grid + bangumi manual bind
This commit is contained in:
parent
61fe323bf8
commit
c7c6e30a56
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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} />
|
||||
|
|
@ -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>
|
||||
|
|
@ -40,6 +40,17 @@ export const en: Record<MessageKey, string> = {
|
|||
"library.scan": "Scan mounts",
|
||||
"library.unmatched": "Unmatched",
|
||||
"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.play": "Play",
|
||||
|
|
|
|||
|
|
@ -41,6 +41,17 @@ export const zhCN = {
|
|||
"library.scan": "扫描挂载",
|
||||
"library.unmatched": "未匹配",
|
||||
"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.play": "播放",
|
||||
|
|
|
|||
Loading…
Reference in New Issue