Merge pull request #20 from fluxis/mr/b-admin-ux
feat(admin): admin libraries UX polish — full-table unmatched search, larger match-candidate posters with hover preview, paginated Ambiguous Roots
This commit is contained in:
@@ -2058,16 +2058,45 @@ func (h *LibraryHandler) HandleListUnmatchedItems(w http.ResponseWriter, r *http
|
||||
}
|
||||
}
|
||||
|
||||
// Optional case-insensitive search across title, library name, type, and
|
||||
// status. Applied server-side so it spans the whole table, not just the
|
||||
// current page. The displayed folder still comes from the lateral join below,
|
||||
// but the search predicate checks every membership so multi-library items are
|
||||
// found when any linked library name matches.
|
||||
search := strings.TrimSpace(q.Get("q"))
|
||||
filter := ""
|
||||
filterArgs := []any{}
|
||||
if search != "" {
|
||||
filterArgs = append(filterArgs, "%"+search+"%")
|
||||
filter = ` AND (
|
||||
mi.title ILIKE $1
|
||||
OR mi.type ILIKE $1
|
||||
OR mi.status ILIKE $1
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM media_item_libraries search_mil
|
||||
JOIN media_folders search_f ON search_f.id = search_mil.media_folder_id
|
||||
WHERE search_mil.content_id = mi.content_id
|
||||
AND search_f.name ILIKE $1
|
||||
)
|
||||
)`
|
||||
}
|
||||
|
||||
countSQL := `
|
||||
SELECT COUNT(*)
|
||||
FROM media_items mi
|
||||
WHERE mi.status IN ('unmatched', 'pending', 'ambiguous')`
|
||||
countSQL += filter
|
||||
|
||||
var total int
|
||||
if err := h.pool.QueryRow(r.Context(),
|
||||
`SELECT COUNT(*) FROM media_items WHERE status IN ('unmatched', 'pending', 'ambiguous')`,
|
||||
).Scan(&total); err != nil {
|
||||
if err := h.pool.QueryRow(r.Context(), countSQL, filterArgs...).Scan(&total); err != nil {
|
||||
slog.Error("counting unmatched items", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to count unmatched items")
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := h.pool.Query(r.Context(), `
|
||||
listArgs := append(append([]any{}, filterArgs...), limit, offset)
|
||||
listSQL := fmt.Sprintf(`
|
||||
SELECT mi.content_id, mi.title, mi.year, mi.type, mi.status,
|
||||
COALESCE(lib.folder_id, 0),
|
||||
COALESCE(lib.folder_name, '')
|
||||
@@ -2079,10 +2108,12 @@ func (h *LibraryHandler) HandleListUnmatchedItems(w http.ResponseWriter, r *http
|
||||
WHERE mil.content_id = mi.content_id
|
||||
LIMIT 1
|
||||
) lib ON true
|
||||
WHERE mi.status IN ('unmatched', 'pending', 'ambiguous')
|
||||
WHERE mi.status IN ('unmatched', 'pending', 'ambiguous')%s
|
||||
ORDER BY mi.title ASC, mi.content_id ASC
|
||||
LIMIT $1 OFFSET $2
|
||||
`, limit, offset)
|
||||
LIMIT $%d OFFSET $%d
|
||||
`, filter, len(filterArgs)+1, len(filterArgs)+2)
|
||||
|
||||
rows, err := h.pool.Query(r.Context(), listSQL, listArgs...)
|
||||
if err != nil {
|
||||
slog.Error("listing unmatched items", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list unmatched items")
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import type { FileVersion, ItemDetail, MatchCandidate } from "@/api/types";
|
||||
import MediaLocations from "@/components/MediaLocations";
|
||||
import { useSearchItemMatchCandidates, useApplyItemMatch } from "@/hooks/queries/items";
|
||||
@@ -170,55 +171,71 @@ export default function MatchItemDialog({ item, open, onOpenChange }: MatchItemD
|
||||
{candidates.length > 0 && (
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 overflow-hidden">
|
||||
<Label className="shrink-0">Results</Label>
|
||||
<div className="overlay-scroll min-h-0 flex-1 space-y-1 overflow-y-auto overscroll-contain pr-1 pb-1">
|
||||
{candidates.map((candidate, index) => {
|
||||
const candidateKey = Object.entries(candidate.provider_ids)
|
||||
.map(([k, v]) => `${k}-${v}`)
|
||||
.join("_");
|
||||
return (
|
||||
<button
|
||||
key={`${candidateKey}-${index}`}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full min-w-0 items-start gap-3 rounded-lg border p-3 text-left transition-colors",
|
||||
selectedCandidate === candidate
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:bg-muted/50",
|
||||
)}
|
||||
onClick={() => setSelectedCandidate(candidate)}
|
||||
data-testid="match-candidate"
|
||||
>
|
||||
{candidate.image_url ? (
|
||||
<img
|
||||
src={candidate.image_url}
|
||||
alt=""
|
||||
className="h-16 w-11 shrink-0 rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-muted h-16 w-11 shrink-0 rounded" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{candidate.title}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{candidate.year ? candidate.year : ""}
|
||||
<TooltipProvider delayDuration={150}>
|
||||
<div className="overlay-scroll min-h-0 flex-1 space-y-1 overflow-y-auto overscroll-contain pr-1 pb-1">
|
||||
{candidates.map((candidate, index) => {
|
||||
const candidateKey = Object.entries(candidate.provider_ids)
|
||||
.map(([k, v]) => `${k}-${v}`)
|
||||
.join("_");
|
||||
return (
|
||||
<button
|
||||
key={`${candidateKey}-${index}`}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full min-w-0 items-start gap-3 rounded-lg border p-3 text-left transition-colors",
|
||||
selectedCandidate === candidate
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:bg-muted/50",
|
||||
)}
|
||||
onClick={() => setSelectedCandidate(candidate)}
|
||||
data-testid="match-candidate"
|
||||
>
|
||||
{candidate.image_url ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<img
|
||||
src={candidate.image_url}
|
||||
alt=""
|
||||
className="h-24 w-16 shrink-0 cursor-zoom-in rounded object-cover"
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
className="border-border/60 overflow-hidden border bg-transparent p-0 shadow-xl"
|
||||
>
|
||||
<img
|
||||
src={candidate.image_url}
|
||||
alt={candidate.title}
|
||||
className="h-72 w-48 rounded-md object-cover"
|
||||
/>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<div className="bg-muted h-24 w-16 shrink-0 rounded" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{candidate.title}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{candidate.year ? candidate.year : ""}
|
||||
</div>
|
||||
<div className="mt-1 flex min-w-0 flex-wrap gap-1">
|
||||
{candidate.sources.map((source) => (
|
||||
<Badge key={source} variant="outline" className="text-[10px]">
|
||||
{source}
|
||||
</Badge>
|
||||
))}
|
||||
{candidate.sources.length > 1 && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{candidate.sources.length} sources agree
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 flex min-w-0 flex-wrap gap-1">
|
||||
{candidate.sources.map((source) => (
|
||||
<Badge key={source} variant="outline" className="text-[10px]">
|
||||
{source}
|
||||
</Badge>
|
||||
))}
|
||||
{candidate.sources.length > 1 && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{candidate.sources.length} sources agree
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -510,13 +510,16 @@ export function useRefreshLibraryMetadata() {
|
||||
|
||||
const UNMATCHED_PAGE_SIZE = 10;
|
||||
|
||||
export function useUnmatchedLibraryItems(page = 0) {
|
||||
export function useUnmatchedLibraryItems(page = 0, search = "") {
|
||||
const offset = page * UNMATCHED_PAGE_SIZE;
|
||||
const trimmed = search.trim();
|
||||
return useQuery({
|
||||
queryKey: adminKeys.unmatchedItems(page),
|
||||
queryKey: adminKeys.unmatchedItems(page, trimmed),
|
||||
queryFn: () =>
|
||||
api<UnmatchedLibraryItemsResponse>(
|
||||
`/libraries/unmatched-items?limit=${UNMATCHED_PAGE_SIZE}&offset=${offset}`,
|
||||
`/libraries/unmatched-items?limit=${UNMATCHED_PAGE_SIZE}&offset=${offset}${
|
||||
trimmed ? `&q=${encodeURIComponent(trimmed)}` : ""
|
||||
}`,
|
||||
).then((d) => d ?? { items: [], total: 0 }),
|
||||
staleTime: ADMIN_STALE_TIME,
|
||||
placeholderData: (prev) => prev,
|
||||
|
||||
@@ -386,9 +386,9 @@ export const adminKeys = {
|
||||
pluginRepositories: () => ["admin", "plugins", "repositories"] as const,
|
||||
pluginCatalog: () => ["admin", "plugins", "catalog"] as const,
|
||||
pluginInstallations: () => ["admin", "plugins", "installations"] as const,
|
||||
unmatchedItems: (page?: number) =>
|
||||
unmatchedItems: (page?: number, search?: string) =>
|
||||
page != null
|
||||
? (["admin", "libraries", "unmatchedItems", page] as const)
|
||||
? (["admin", "libraries", "unmatchedItems", page, search ?? ""] as const)
|
||||
: (["admin", "libraries", "unmatchedItems"] as const),
|
||||
itemImages: (id: string) => ["admin", "items", id, "images"] as const,
|
||||
buildInfo: () => ["admin", "system", "buildInfo"] as const,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -23,6 +24,11 @@ const mocks = vi.hoisted(() => ({
|
||||
useDeleteLibraryPoster: vi.fn(),
|
||||
useUnmatchedLibraryItems: vi.fn(),
|
||||
useAdminPlugins: vi.fn(),
|
||||
useCancelLibraryScans: vi.fn(),
|
||||
useLibraryRoots: vi.fn(),
|
||||
useUpsertLibraryRootOverride: vi.fn(),
|
||||
useDeleteLibraryRootOverride: vi.fn(),
|
||||
useActiveScans: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/queries/admin/libraries", () => ({
|
||||
@@ -45,14 +51,38 @@ vi.mock("@/hooks/queries/admin/libraries", () => ({
|
||||
useUploadLibraryPoster: (...args: unknown[]) => mocks.useUploadLibraryPoster(...args),
|
||||
useDeleteLibraryPoster: (...args: unknown[]) => mocks.useDeleteLibraryPoster(...args),
|
||||
useUnmatchedLibraryItems: (...args: unknown[]) => mocks.useUnmatchedLibraryItems(...args),
|
||||
useCancelLibraryScans: (...args: unknown[]) => mocks.useCancelLibraryScans(...args),
|
||||
useLibraryRoots: (...args: unknown[]) => mocks.useLibraryRoots(...args),
|
||||
useUpsertLibraryRootOverride: (...args: unknown[]) => mocks.useUpsertLibraryRootOverride(...args),
|
||||
useDeleteLibraryRootOverride: (...args: unknown[]) => mocks.useDeleteLibraryRootOverride(...args),
|
||||
UNMATCHED_PAGE_SIZE: 10,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/queries/admin/plugins", () => ({
|
||||
useAdminPlugins: (...args: unknown[]) => mocks.useAdminPlugins(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/queries/admin/scans", () => ({
|
||||
useActiveScans: (...args: unknown[]) => mocks.useActiveScans(...args),
|
||||
}));
|
||||
|
||||
import AdminLibraries from "./AdminLibraries";
|
||||
|
||||
// renderPage wraps the page in the providers it needs at runtime: a
|
||||
// QueryClientProvider for the (mocked) TanStack hooks, and a MemoryRouter for
|
||||
// the <Link>s inside AdminLibraries. Without QueryClientProvider, even fully
|
||||
// mocked useQuery hooks throw "No QueryClient set" during render.
|
||||
const renderPage = () => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={client}>
|
||||
<MemoryRouter>
|
||||
<AdminLibraries />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe("AdminLibraries", () => {
|
||||
beforeEach(() => {
|
||||
const mutate = vi.fn();
|
||||
@@ -114,17 +144,18 @@ describe("AdminLibraries", () => {
|
||||
isLoading: false,
|
||||
});
|
||||
mocks.useUnmatchedLibraryItems.mockReturnValue({
|
||||
data: [],
|
||||
data: { items: [], total: 0 },
|
||||
isLoading: false,
|
||||
});
|
||||
mocks.useCancelLibraryScans.mockReturnValue(queryState);
|
||||
mocks.useLibraryRoots.mockReturnValue({ data: [], isLoading: false });
|
||||
mocks.useUpsertLibraryRootOverride.mockReturnValue(queryState);
|
||||
mocks.useDeleteLibraryRootOverride.mockReturnValue(queryState);
|
||||
mocks.useActiveScans.mockReturnValue({ data: [], isLoading: false });
|
||||
});
|
||||
|
||||
it("uses scan language instead of metadata refresh language on the admin libraries page", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<MemoryRouter>
|
||||
<AdminLibraries />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
const markup = renderPage();
|
||||
|
||||
expect(markup).toContain(
|
||||
"Manage library roots and scans. Catalog import/export now lives under Maintenance.",
|
||||
@@ -137,16 +168,20 @@ describe("AdminLibraries", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("renders a low-key troubleshooting section only when skipped roots exist", () => {
|
||||
mocks.useSkippedLibraryRoots.mockReturnValue({
|
||||
it("renders the Ambiguous Roots section with a populated row", () => {
|
||||
mocks.useLibraryRoots.mockReturnValue({
|
||||
data: [
|
||||
{
|
||||
library_id: 1,
|
||||
library_name: "Movies",
|
||||
root_path: "/media/movies/Inception (2010)",
|
||||
reason: "missing_folder_ids",
|
||||
state: "ambiguous",
|
||||
inferred_type: "movie",
|
||||
type_confidence: "low",
|
||||
title: "Inception",
|
||||
year: 2010,
|
||||
observed_file_count: 1,
|
||||
sample_file_path: "/media/movies/Inception (2010)/Inception (2010).mkv",
|
||||
file_count: 1,
|
||||
first_seen_at: "2026-03-23T20:00:00Z",
|
||||
last_seen_at: "2026-03-23T21:00:00Z",
|
||||
},
|
||||
@@ -154,29 +189,21 @@ describe("AdminLibraries", () => {
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<MemoryRouter>
|
||||
<AdminLibraries />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
const markup = renderPage();
|
||||
|
||||
expect(markup).toContain("Troubleshooting");
|
||||
expect(markup).toContain("Root path");
|
||||
expect(markup).toContain("Movies");
|
||||
expect(markup).toContain("Ambiguous Roots");
|
||||
expect(markup).toContain("Inception");
|
||||
expect(markup).toContain("/media/movies/Inception (2010)");
|
||||
expect(markup).toContain("missing_folder_ids");
|
||||
expect(markup).toContain("First seen");
|
||||
expect(markup).toContain("Last seen");
|
||||
});
|
||||
|
||||
it("hides the troubleshooting section when no skipped roots exist", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<MemoryRouter>
|
||||
<AdminLibraries />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
it("renders the empty-state inside Ambiguous Roots when no roots exist", () => {
|
||||
// Default useLibraryRoots mock returns { data: [], isLoading: false }. The
|
||||
// section itself still renders (it's gated on libraries.length, not on the
|
||||
// root list), and the table body shows the empty-state copy.
|
||||
const markup = renderPage();
|
||||
|
||||
expect(markup).not.toContain("Root path");
|
||||
expect(markup).toContain("Ambiguous Roots");
|
||||
expect(markup).toContain("No ambiguous roots for this library");
|
||||
});
|
||||
|
||||
it("renders Match instead of Re-match for stale IDs", () => {
|
||||
@@ -198,11 +225,7 @@ describe("AdminLibraries", () => {
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<MemoryRouter>
|
||||
<AdminLibraries />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
const markup = renderPage();
|
||||
|
||||
expect(markup).toContain("Match");
|
||||
expect(markup).not.toContain("Re-match");
|
||||
@@ -210,25 +233,24 @@ describe("AdminLibraries", () => {
|
||||
|
||||
it("renders an unmatched items section when unmatched items exist", () => {
|
||||
mocks.useUnmatchedLibraryItems.mockReturnValue({
|
||||
data: [
|
||||
{
|
||||
content_id: "movie-99",
|
||||
title: "Unknown Film",
|
||||
year: 0,
|
||||
content_type: "movie",
|
||||
library_id: 1,
|
||||
library_name: "Movies",
|
||||
status: "unmatched",
|
||||
},
|
||||
],
|
||||
data: {
|
||||
items: [
|
||||
{
|
||||
content_id: "movie-99",
|
||||
title: "Unknown Film",
|
||||
year: 0,
|
||||
content_type: "movie",
|
||||
library_id: 1,
|
||||
library_name: "Movies",
|
||||
status: "unmatched",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<MemoryRouter>
|
||||
<AdminLibraries />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
const markup = renderPage();
|
||||
|
||||
expect(markup).toContain("Unmatched Items");
|
||||
expect(markup).toContain("Unknown Film");
|
||||
@@ -236,11 +258,7 @@ describe("AdminLibraries", () => {
|
||||
});
|
||||
|
||||
it("hides unmatched items section when no unmatched items exist", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<MemoryRouter>
|
||||
<AdminLibraries />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
const markup = renderPage();
|
||||
|
||||
expect(markup).not.toContain("Unmatched Items");
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Fragment, useState, useEffect, useCallback, useMemo } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { useDebounce } from "@/hooks/useDebounce";
|
||||
import { useEventChannel } from "@/components/realtimeEventsContext";
|
||||
import type {
|
||||
AdminJob,
|
||||
@@ -1150,6 +1151,8 @@ function AmbiguousRootsSection({ libraries }: { libraries: Library[] }) {
|
||||
);
|
||||
}, [roots, search]);
|
||||
|
||||
const pag = usePagination(filteredRoots);
|
||||
|
||||
if (libraries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
@@ -1177,7 +1180,10 @@ function AmbiguousRootsSection({ libraries }: { libraries: Library[] }) {
|
||||
value={
|
||||
effectiveSelectedLibraryId != null ? String(effectiveSelectedLibraryId) : undefined
|
||||
}
|
||||
onValueChange={(value) => setSelectedLibraryId(Number.parseInt(value, 10))}
|
||||
onValueChange={(value) => {
|
||||
setSelectedLibraryId(Number.parseInt(value, 10));
|
||||
pag.setPage(0);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-full text-xs sm:w-[220px]">
|
||||
<SelectValue placeholder="Select library" />
|
||||
@@ -1195,7 +1201,10 @@ function AmbiguousRootsSection({ libraries }: { libraries: Library[] }) {
|
||||
<Input
|
||||
placeholder="Filter by path, title, or sample file..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
pag.setPage(0);
|
||||
}}
|
||||
className="h-8 pl-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
@@ -1220,7 +1229,7 @@ function AmbiguousRootsSection({ libraries }: { libraries: Library[] }) {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredRoots.map((root) => (
|
||||
pag.rows.map((root) => (
|
||||
<TableRow key={`${root.library_id}:${root.root_path}`}>
|
||||
<TableCell className="max-w-[28rem]">
|
||||
<div className="space-y-1">
|
||||
@@ -1262,6 +1271,7 @@ function AmbiguousRootsSection({ libraries }: { libraries: Library[] }) {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<PaginationBar {...pag} />
|
||||
</div>
|
||||
|
||||
{editingRoot ? (
|
||||
@@ -1646,27 +1656,20 @@ function SkippedRootsSection({ skippedRoots }: { skippedRoots: LibrarySkippedRoo
|
||||
function UnmatchedItemsSection() {
|
||||
const [page, setPage] = useState(0);
|
||||
const [search, setSearch] = useState("");
|
||||
const debouncedSearch = useDebounce(search, 250);
|
||||
const [matchItem, setMatchItem] = useState<UnmatchedLibraryItem | null>(null);
|
||||
const { data } = useUnmatchedLibraryItems(page);
|
||||
const { data } = useUnmatchedLibraryItems(page, debouncedSearch);
|
||||
const total = data?.total ?? 0;
|
||||
const totalPages = Math.max(1, Math.ceil(total / UNMATCHED_PAGE_SIZE));
|
||||
const clamped = Math.min(page, totalPages - 1);
|
||||
const rangeStart = total === 0 ? 0 : clamped * UNMATCHED_PAGE_SIZE + 1;
|
||||
const rangeEnd = Math.min((clamped + 1) * UNMATCHED_PAGE_SIZE, total);
|
||||
const filteredItems = useMemo(() => {
|
||||
const items = data?.items ?? [];
|
||||
if (!search) return items;
|
||||
const q = search.toLowerCase();
|
||||
return items.filter(
|
||||
(item) =>
|
||||
item.title.toLowerCase().includes(q) ||
|
||||
item.library_name.toLowerCase().includes(q) ||
|
||||
item.content_type.toLowerCase().includes(q) ||
|
||||
item.status.toLowerCase().includes(q),
|
||||
);
|
||||
}, [data?.items, search]);
|
||||
const items = data?.items ?? [];
|
||||
|
||||
if (total === 0 && page === 0) return null;
|
||||
// Hide the section only when there are genuinely no unmatched items and no
|
||||
// active search — keep it mounted while searching so the box and the
|
||||
// "no matches" state stay visible even when a query returns nothing.
|
||||
if (total === 0 && page === 0 && search.trim() === "") return null;
|
||||
|
||||
return (
|
||||
<section className="surface-panel-subtle overflow-hidden rounded-2xl">
|
||||
@@ -1689,9 +1692,14 @@ function UnmatchedItemsSection() {
|
||||
<div className="relative mb-2">
|
||||
<Search className="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 h-3.5 w-3.5 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="Filter this page by title, library, or type..."
|
||||
placeholder="Search all unmatched items by title, library, or type..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onChange={(e) => {
|
||||
// Search is server-side and spans the whole table; jump back to
|
||||
// the first page so results start at the top as the query changes.
|
||||
setSearch(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
className="h-8 pl-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
@@ -1707,14 +1715,14 @@ function UnmatchedItemsSection() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredItems.length === 0 ? (
|
||||
{items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-muted-foreground text-center text-sm">
|
||||
No unmatched items on this page match your filter.
|
||||
No unmatched items match your search.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredItems.map((u) => (
|
||||
items.map((u) => (
|
||||
<TableRow key={u.content_id}>
|
||||
<TableCell className="font-medium">
|
||||
<Link
|
||||
|
||||
Reference in New Issue
Block a user