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:
Quick
2026-05-28 11:41:03 -04:00
committed by GitHub
6 changed files with 213 additions and 136 deletions
+38 -7
View File
@@ -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")
+65 -48
View File
@@ -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>
)}
+6 -3
View File
@@ -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,
+2 -2
View File
@@ -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,
+72 -54
View File
@@ -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");
});
+30 -22
View File
@@ -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