338 lines
14 KiB
TypeScript
338 lines
14 KiB
TypeScript
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>
|
||
);
|
||
}
|