app-template/app/web/src/components/pages/BrowseBody.tsx

338 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { Locale } from "@app/i18n";
import { t } from "@app/i18n";
import { isVideoFilename } from "@app/types";
import { Button, Input, toast } from "@app/ui";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { go, queryParam, setQueryParams } from "~/lib/nav";
import { createTRPCReactClient, trpc } from "~/lib/trpc";
function pathOf(segments: string[]): string {
return segments.length > 0 ? `/${segments.join("/")}` : "/";
}
function fileRelPath(dirPath: string, basename: string): string {
return dirPath === "/" ? `/${basename}` : `${dirPath.replace(/\/+$/, "")}/${basename}`;
}
function BrowseInner(props: { locale: Locale }) {
const { locale } = props;
const mounts = trpc.media.mountList.useQuery();
const [mountId, setMountId] = useState(() => queryParam("mount") ?? "");
const [path, setPath] = useState("/");
const [crumbs, setCrumbs] = useState<string[]>([]);
useEffect(() => {
setQueryParams({ mount: mountId || null });
}, [mountId]);
// 挂载列表返回后:URL 里的 mount 有效则保留;否则自动选中第一个已挂载目录
useEffect(() => {
const list = mounts.data?.mounts ?? [];
if (list.length === 0) return;
if (mountId && list.some((m) => m.id === mountId)) return;
const first = list[0];
if (first) setMountId(first.id);
}, [mounts.data, mountId]);
const dir = trpc.media.mountListDir.useQuery({ mountId, path }, { enabled: Boolean(mountId) });
const scan = trpc.media.libraryScan.useMutation();
const ensure = trpc.media.libraryEnsurePath.useMutation();
const utils = trpc.useUtils();
const batchScrape = trpc.media.scrapeRescrapePath.useMutation({
onSuccess: (data) => {
toast.success(t(locale, "browse.batchDone", { message: data.message }));
void utils.media.libraryList.invalidate();
void utils.media.libraryGroups.invalidate();
},
onError: () => toast.error(t(locale, "browse.batchError")),
});
const bindPath = trpc.media.scrapeBindPath.useMutation({
onSuccess: (data) => {
toast.success(t(locale, "browse.batchDone", { message: data.message }));
setBindOpen(false);
setBindQ("");
void utils.media.libraryList.invalidate();
void utils.media.libraryGroups.invalidate();
},
onError: () => toast.error(t(locale, "browse.batchError")),
});
const [bindOpen, setBindOpen] = useState(false);
const [bindQ, setBindQ] = useState("");
const bindSearch = trpc.media.scrapeSearch.useQuery(
{ q: bindQ },
{ enabled: bindOpen && bindQ.length > 1 },
);
function goRoot() {
setCrumbs([]);
setPath("/");
}
function goUp() {
const next = crumbs.slice(0, -1);
setCrumbs(next);
setPath(pathOf(next));
}
function goToCrumb(index: number) {
const next = crumbs.slice(0, index + 1);
setCrumbs(next);
setPath(pathOf(next));
}
function enter(name: string) {
const next = [...crumbs, name];
setCrumbs(next);
setPath(pathOf(next));
}
async function playFile(basename: string) {
if (!mountId) return;
try {
const item = await ensure.mutateAsync({ mountId, path: fileRelPath(path, basename) });
go(`/${locale}/watch?id=${item.id}`);
} catch {
// error surfaced via ensure.error
}
}
const mountList = mounts.data?.mounts ?? [];
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-xl font-semibold">{t(locale, "browse.title")}</h1>
<select
className="h-9 rounded-md border border-border bg-background px-2 text-sm"
value={mountId}
disabled={mounts.isPending || mountList.length === 0}
onChange={(e) => {
setMountId(e.target.value);
setCrumbs([]);
setPath("/");
scan.reset();
ensure.reset();
}}
>
<option value="">
{mountList.length === 0
? t(locale, "browse.mountsEmpty")
: t(locale, "browse.selectMount")}
</option>
{mountList.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
{mountId && (
<Button
size="sm"
variant="outline"
disabled={scan.isPending}
onClick={() => scan.mutate({ mountId })}
>
{t(locale, "browse.addLibrary")}
</Button>
)}
{mountId && path !== "/" && (
<>
<Button
size="sm"
disabled={batchScrape.isPending}
onClick={() => batchScrape.mutate({ mountId, path })}
>
{t(locale, "browse.batchScrape")}
</Button>
<Button size="sm" variant="secondary" onClick={() => setBindOpen(true)}>
{t(locale, "browse.batchBind")}
</Button>
</>
)}
</div>
{(batchScrape.data || bindPath.data) && (
<p className="text-sm text-muted-foreground">
{t(locale, "browse.batchDone", {
message: batchScrape.data?.message ?? bindPath.data?.message ?? "",
})}
</p>
)}
{scan.data && (
<div className="flex flex-wrap items-center gap-3">
<p className="text-sm text-muted-foreground">
{t(locale, "browse.scanDone", { count: scan.data.scanned })}
</p>
<Button size="sm" onClick={() => go(`/${locale}/library`)}>
{t(locale, "browse.goLibrary")}
</Button>
</div>
)}
{scan.isError && (
<p className="text-sm text-red-600">{t(locale, "browse.scanError")}</p>
)}
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<button type="button" onClick={goRoot}>
{t(locale, "browse.root")}
</button>
{crumbs.map((c, i) => (
<span key={crumbs.slice(0, i + 1).join("/")}>
{" / "}
<button type="button" onClick={() => goToCrumb(i)}>
{c}
</button>
</span>
))}
{crumbs.length > 0 && (
<button type="button" onClick={goUp}>
{t(locale, "browse.up")}
</button>
)}
</div>
{mountId && dir.isPending && (
<p className="text-sm text-muted-foreground">{t(locale, "browse.loading")}</p>
)}
{mountId && dir.isError && (
<div className="flex flex-wrap items-center gap-3">
<p className="text-sm text-red-600">
{t(locale, "browse.loadError")}
{dir.error instanceof Error && dir.error.message
? `:${dir.error.message}`
: ""}
</p>
<Button size="sm" variant="outline" onClick={() => void dir.refetch()}>
{t(locale, "error.retry")}
</Button>
</div>
)}
{ensure.isError && (
<p className="text-sm text-red-600">{t(locale, "browse.playError")}</p>
)}
<ul className="divide-y divide-border rounded-lg border border-border">
{(dir.data?.entries ?? []).map((e) => {
const isVideo = e.type === "file" && isVideoFilename(e.basename);
return (
<li
key={e.filename}
className="flex items-center justify-between gap-3 p-3 text-sm"
>
{e.type === "directory" ? (
<button
type="button"
className="text-left font-medium"
onClick={() => enter(e.basename)}
>
{e.basename}
</button>
) : (
<span className={isVideo ? "font-medium" : "text-muted-foreground"}>
{e.basename}
</span>
)}
{isVideo && (
<Button
size="sm"
variant="secondary"
disabled={ensure.isPending}
onClick={() => playFile(e.basename)}
>
{t(locale, "browse.play")}
</Button>
)}
</li>
);
})}
{mountId && dir.data?.entries.length === 0 && (
<li className="p-0">
<div className="u-empty border-0 bg-transparent">
<p>{t(locale, "browse.empty")}</p>
</div>
</li>
)}
{!mountId && mountList.length === 0 && (
<li className="p-0">
<div className="u-empty border-0 bg-transparent">
<p className="u-empty__title">{t(locale, "browse.mountsEmpty")}</p>
<div className="u-empty__actions">
<Button size="sm" onClick={() => go(`/${locale}/mounts`)}>
{t(locale, "browse.addMount")}
</Button>
</div>
</div>
</li>
)}
{!mountId && mountList.length > 0 && (
<li className="p-0">
<div className="u-empty border-0 bg-transparent">
<p>{t(locale, "browse.loading")}</p>
</div>
</li>
)}
</ul>
{bindOpen && mountId && (
<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, "browse.bindFolderTitle")}</h2>
<p className="text-xs text-muted-foreground">
{t(locale, "browse.bindFolderHint")}
</p>
<p className="font-mono text-xs text-muted-foreground">{path}</p>
<Input
placeholder={t(locale, "library.searchWork")}
value={bindQ}
onChange={(e) => setBindQ(e.target.value)}
/>
<ul className="max-h-64 divide-y divide-border overflow-auto rounded border border-border">
{(bindSearch.data ?? []).map((hit) => (
<li
key={hit.id}
className="flex items-center justify-between gap-2 p-2 text-sm"
>
<div className="min-w-0">
<div className="truncate">{hit.nameCn || hit.name}</div>
{hit.nameCn && hit.name && (
<div className="truncate text-xs text-muted-foreground">
{hit.name}
</div>
)}
</div>
<Button
size="sm"
disabled={bindPath.isPending}
onClick={() =>
bindPath.mutate({
mountId,
path,
bangumiId: hit.id,
})
}
>
{t(locale, "library.bind")}
</Button>
</li>
))}
</ul>
<Button variant="outline" onClick={() => setBindOpen(false)}>
{t(locale, "library.cancel")}
</Button>
</div>
</div>
)}
</div>
);
}
const client = createTRPCReactClient();
export function BrowseBody(props: { locale: Locale }) {
const [qc] = useState(() => new QueryClient());
return (
<QueryClientProvider client={qc}>
<trpc.Provider client={client} queryClient={qc}>
<BrowseInner locale={props.locale} />
</trpc.Provider>
</QueryClientProvider>
);
}