diff --git a/app/web/src/components/pages/LibraryBody.tsx b/app/web/src/components/pages/LibraryBody.tsx index fcd3031..c253bdb 100644 --- a/app/web/src/components/pages/LibraryBody.tsx +++ b/app/web/src/components/pages/LibraryBody.tsx @@ -1,12 +1,13 @@ import type { Locale } from "@app/i18n"; import { t } from "@app/i18n"; -import type { LibraryGroup } from "@app/types"; -import { Button, Input, toast } from "@app/ui"; +import { parseEpisodeFromFilename, type LibraryGroup } from "@app/types"; +import { Badge, Button, Input, toast } from "@app/ui"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useEffect, useRef, useState } from "react"; import { cn } from "~/lib/utils"; import { go, queryParam, setQueryParams } from "~/lib/nav"; import { createTRPCReactClient, trpc } from "~/lib/trpc"; +import { formatTrpcError } from "~/lib/trpc-error"; type ViewMode = "wall" | "list"; @@ -78,7 +79,8 @@ function GroupDetail(props: { locale: Locale; onBack: () => void; onPlay: (mediaItemId: string, bangumiId: number) => void; - onMatch?: ((mediaItemId: string) => void) | undefined; + onMatch?: ((mediaItemId: string, rawName?: string) => void) | undefined; + onManage?: ((group: ManageTarget) => void) | undefined; }) { const { locale } = props; const seriesQuery = trpc.media.librarySeries.useQuery( @@ -103,9 +105,29 @@ function GroupDetail(props: {
-

- {group.title} -

+
+

+ {group.title} +

+ {props.onManage && ( + + )} +

{t(locale, "library.episodeCount", { count: group.episodes.length })}

@@ -114,38 +136,45 @@ function GroupDetail(props: { {t(locale, "library.episodes")}
@@ -157,7 +186,26 @@ function GroupDetail(props: { function ContinueRow(props: { locale: Locale }) { const { locale } = props; + const utils = trpc.useUtils(); const list = trpc.media.playbackContinue.useQuery({ limit: 6 }); + const [confirmRemoveId, setConfirmRemoveId] = useState(null); + const [confirmClear, setConfirmClear] = useState(false); + const remove = trpc.media.playbackDelete.useMutation({ + onSuccess: () => { + setConfirmRemoveId(null); + toast.success(t(locale, "library.removedToast")); + void utils.media.playbackContinue.invalidate(); + }, + onError: (err) => toast.error(formatTrpcError(err, t(locale, "library.removeError"))), + }); + const clear = trpc.media.playbackClear.useMutation({ + onSuccess: () => { + setConfirmClear(false); + toast.success(t(locale, "library.clearedToast")); + void utils.media.playbackContinue.invalidate(); + }, + onError: (err) => toast.error(formatTrpcError(err, t(locale, "library.clearError"))), + }); const items = list.data ?? []; if (list.isPending) return null; if (items.length === 0) return null; @@ -166,6 +214,20 @@ function ContinueRow(props: { locale: Locale }) {

{t(locale, "library.continueTitle")}

{items.map((item) => { @@ -174,28 +236,56 @@ function ContinueRow(props: { locale: Locale }) { ? Math.min(100, Math.round((item.positionMs / item.durationMs) * 100)) : 0; return ( - + + +
); })} @@ -257,6 +347,245 @@ function usePagedAccumulator(params: { return { items, total, hasMore: items.length < total }; } +type ManageTarget = { bangumiId: number; title: string; posterUrl: string | null }; + +/** 海报墙组管理弹窗:编辑标题/封面、重新匹配、取消匹配、删除记录 */ +function GroupManageModal(props: { + locale: Locale; + mountId: string; + group: ManageTarget; + onClose: () => void; + onChanged: () => void; + onRemoved: (bangumiId: number) => void; +}) { + const { locale, mountId, group } = props; + const [tab, setTab] = useState<"edit" | "rematch">("edit"); + const [title, setTitle] = useState(group.title); + const [poster, setPoster] = useState(group.posterUrl ?? ""); + const [searchQ, setSearchQ] = useState(""); + const [confirmUnbind, setConfirmUnbind] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(false); + + const search = trpc.media.scrapeSearch.useQuery( + { q: searchQ }, + { enabled: tab === "rematch" && searchQ.length > 1 }, + ); + const save = trpc.media.libraryUpdateGroup.useMutation({ + onSuccess: () => { + toast.success(t(locale, "library.saved")); + props.onChanged(); + }, + onError: (err) => toast.error(formatTrpcError(err, t(locale, "library.groupOpError"))), + }); + const unbind = trpc.media.scrapeUnbindGroup.useMutation({ + onSuccess: () => { + toast.success(t(locale, "library.unbound")); + props.onChanged(); + props.onRemoved(group.bangumiId); + props.onClose(); + }, + onError: (err) => toast.error(formatTrpcError(err, t(locale, "library.groupOpError"))), + }); + const remove = trpc.media.libraryDeleteGroup.useMutation({ + onSuccess: () => { + toast.success(t(locale, "library.groupDeleted")); + props.onChanged(); + props.onRemoved(group.bangumiId); + props.onClose(); + }, + onError: (err) => toast.error(formatTrpcError(err, t(locale, "library.groupOpError"))), + }); + const rebind = trpc.media.scrapeBindGroup.useMutation({ + onSuccess: () => { + toast.success(t(locale, "library.matched")); + props.onChanged(); + setTab("edit"); + setSearchQ(""); + }, + onError: (err) => toast.error(formatTrpcError(err, t(locale, "library.groupOpError"))), + }); + + const busy = save.isPending || unbind.isPending || remove.isPending || rebind.isPending; + const canSave = title.trim().length > 0; + + return ( +
+
+
+

+ {t(locale, "library.groupManageTitle")} +

+ + {title} + +
+
+ + +
+ + {tab === "edit" ? ( +
+
+ + setTitle(e.target.value)} + /> +
+
+ + setPoster(e.target.value)} + /> +
+
+ +
+

+ {t(locale, "library.groupOpHint")} +

+
+ + +
+
+ ) : ( +
+ setSearchQ(e.target.value)} + /> +

+ {t(locale, "library.candidates")} +

+
    + {(search.data ?? []).map((hit) => ( +
  • +
    +
    {hit.nameCn || hit.name}
    + {hit.nameCn && hit.name && ( +
    + {hit.name} +
    + )} +
    + +
  • + ))} +
+
+ )} + +
+ +
+
+
+ ); +} + function WallView(props: { locale: Locale; mountId: string; @@ -265,7 +594,8 @@ function WallView(props: { selectedBangumiId: number | null; onSelect: (bangumiId: number | null) => void; onPlay: (mediaItemId: string, bangumiId: number) => void; - onMatch?: ((mediaItemId: string) => void) | undefined; + onMatch?: ((mediaItemId: string, rawName?: string) => void) | undefined; + onManage?: ((group: ManageTarget) => void) | undefined; onClearSearch?: (() => void) | undefined; hideEmpty?: boolean; mountsEmpty?: boolean; @@ -307,6 +637,7 @@ function WallView(props: { onBack={() => props.onSelect(null)} onPlay={props.onPlay} onMatch={props.onMatch} + onManage={props.onManage} /> ); } @@ -318,22 +649,40 @@ function WallView(props: {
{groups.map((group) => ( -
- + + {props.onManage && ( + + )} +
))} {showEmpty && (
@@ -388,7 +737,7 @@ function ListView(props: { filter: "" | "unmatched" | "ok"; refreshKey: number; onFilterChange: (f: "" | "unmatched" | "ok") => void; - onMatch: (mediaItemId: string) => void; + onMatch: (mediaItemId: string, rawName?: string) => void; onPlay: (mediaItemId: string, bangumiId?: number | null) => void; onRescrape?: ((mediaItemId: string) => void) | undefined; onClearSearch?: (() => void) | undefined; @@ -420,6 +769,7 @@ function ListView(props: { type MediaListItem = { id: string; title: string; + rawName: string; posterUrl: string | null; bangumiId: number | null; scrapeStatus: string; @@ -433,6 +783,7 @@ function ListView(props: { items: list.data.rows.map((r) => ({ id: r.id, title: r.title, + rawName: r.rawName, posterUrl: r.posterUrl, bangumiId: r.bangumiId, scrapeStatus: r.scrapeStatus, @@ -444,6 +795,16 @@ function ListView(props: { const rows = paged.items; const hasMore = paged.hasMore; + const statusBadge = (status: string) => { + if (status === "ok") + return { variant: "secondary" as const, label: t(locale, "library.filterOk") }; + if (status === "unmatched") + return { variant: "default" as const, label: t(locale, "library.unmatched") }; + if (status === "failed") + return { variant: "destructive" as const, label: t(locale, "library.statusFailed") }; + return { variant: "outline" as const, label: t(locale, "library.statusPending") }; + }; + return (
@@ -461,89 +822,112 @@ function ListView(props: {
-
- {rows.map((item) => ( -
- -
-
- {item.title} -
-
- - - -
-
-
- ))} - {!props.hideEmpty && - (props.mountsEmpty || (rows.length === 0 && !list.isPending)) && ( -
-

{t(locale, "library.title")}

-

- {!props.mountsEmpty && q - ? t(locale, "library.noResults", { q }) - : !props.mountsEmpty && filter - ? t(locale, "library.filterEmpty") - : t(locale, "library.empty")} -

-
- {!props.mountsEmpty && q && props.onClearSearch ? ( - - ) : !props.mountsEmpty && filter ? ( - - ) : ( - <> - - - )} -
-
- )} -
+ +
+ + ); + })} + +
+ )} + {!props.hideEmpty && (props.mountsEmpty || (rows.length === 0 && !list.isPending)) && ( +
+

{t(locale, "library.title")}

+

+ {!props.mountsEmpty && q + ? t(locale, "library.noResults", { q }) + : !props.mountsEmpty && filter + ? t(locale, "library.filterEmpty") + : t(locale, "library.empty")} +

+
+ {!props.mountsEmpty && q && props.onClearSearch ? ( + + ) : !props.mountsEmpty && filter ? ( + + ) : ( + <> + + + + )} +
+
+ )} {hasMore && (