feat(audiobooks): make audiobook libraries first-class catalog items (#73)
* docs(audiobooks): design spec for plugin absorption Plan to absorb silo-plugin-audiobooks into silo-server as a first-party feature. Audiobooks land in silo's existing SPA; ABS clients connect directly. Hard constraints: reuse existing tables (media_items, media_files, user_watch_progress, user_playback_sessions, people, item_people, library_collections); only two new tables (abs_sessions, podcast_feeds) and at most one column add (media_libraries.kind); silo's main :8080 listener handles ABS Socket.io natively. Out of scope: audiobook requests flow, smart collections, share links, external recommender, custom metadata providers, separate audiobook SPA. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(audiobooks): implementation plan sub-plan 1 (discovery + schema) First of six sub-plans for the absorption. Six tasks: a discovery audit that resolves the spec's Risk questions, four idempotent SQL migrations (abs_sessions, podcast_feeds, media_libraries.kind, audiobooks.enabled feature flag), and an empty-but-compiling internal/audiobooks package scaffolded into cmd/silo. Lands as a strict no-op for users (feature flag defaults to false). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(audiobooks): discovery findings for absorption sub-plan 1 Locks schema/code decisions for migrations 139-142 and downstream sub-plans. Resolves open Risk questions from the absorption design spec. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(audiobooks): migration 139 add abs_sessions table Parallel of jellycompat_sessions for Audiobookshelf-compatible clients. Lets ABS mobile/desktop apps maintain a device-bound session that silo's audiobooks/abs handlers will validate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * style(audiobooks): match codebase conventions in migration 139 Lowercases type keywords in the abs_sessions CREATE TABLE body to match neighboring migrations, fixes the client_version column alignment, and replaces the misleading "parallel to jellycompat_sessions" header comment with a more accurate description of the table's role. Cosmetic only — the running schema is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(audiobooks): migration 140 add podcast_feeds table Side table on media_items for RSS-subscribed podcasts. Holds feed URL, ETag/Last-Modified for conditional fetches, last-refresh timestamp, and the per-feed refresh interval consumed by the upcoming podcastfeed.Refresher scheduled task. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * style(audiobooks): uppercase PRIMARY KEY in migration 140 Aligns with the codebase convention (type keywords lowercase, constraint keywords uppercase) established in migration 139's post-style-fix form. Cosmetic only — running schema is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(audiobooks): migration 141 no-op for media_folders.type Sub-plan 1 originally reserved migration 141 to add a 'kind' column to media_libraries discriminating audiobook/podcast libraries. Discovery audit (sub-plan 1 Task 1) found that the actual table is media_folders and it already has a type text NOT NULL column with no CHECK constraint or enum, so 'audiobooks' and 'podcasts' can be added as future values without DDL. Landing this migration as a documented no-op preserves the version numbering audit trail and pins the decision in git history. The matching down migration is also a no-op. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(audiobooks): migration 142 add audiobooks.enabled flag Server-settings row that gates the absorbed audiobooks feature. Defaults to 'false' so sub-plan 1 lands as a strict no-op; subsequent sub-plans branch on this flag and operators flip it to 'true' at cutover. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(audiobooks): scaffold internal/audiobooks package Empty-but-compiling Service that reads the audiobooks.enabled feature flag from server_settings. Wired into cmd/silo so the package is referenced from the binary; no routes mounted, no scheduled tasks registered, no DB writes. Subsequent sub-plans hang scanner branches, ABS handlers, Socket.io, podcast refresher, and SPA pages off this Service. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * style(audiobooks): cosmetic cleanups in scaffolded package Two pre-emptive cleanups flagged by code review before sub-plan 2 copies the patterns: 1. Sort the internal/audiobooks import after internal/adminjob in cmd/silo/main.go (alphabetical). 2. Drop the redundant "audiobooks: " prefix from the Enabled() error wrap; matches how every other top-level service package (watchstate, scanqueue, metadata, etc.) formats errors. No behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(audiobooks): implementation plan sub-plan 2 (scanner) Second of six sub-plans. 10 tasks: PersonKind constants for Author and Narrator, audio-extension recognizer, library-type helpers, a walkLogicalTree refactor (movieLibrary bool -> typed walkMode), chapter extraction via ffprobe, single-file and multi-file audiobook parsers, scanner write path producing media_items.type='audiobook', and a filesystem podcast parser (RSS deferred to sub-plan 5). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(audiobooks): add Author and Narrator PersonKind constants Discovery audit confirmed item_people.kind is unconstrained smallint with values 1-6 in use. Reserve 7 = Author, 8 = Narrator for audiobook people-links written by the upcoming scanner branches. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(audiobooks): add audio-extension recognizer for scanner Mirrors the existing videoExtensions/SupportsVideoFile pair. Used by upcoming audiobook and podcast scanner branches to filter directory walks. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(audiobooks): library-type recognizers for scanner dispatch isAudiobookLibraryType and isPodcastLibraryType match singular and plural forms case-insensitively, mirroring isMovieLibraryType. Used by upcoming scanner walk branches (Task 4) that filter audio files into audiobook and podcast libraries. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(scanner): replace movieLibrary bool with typed walkMode Lets walkLogicalTree dispatch on multiple library shapes (video, movie, audiobook, podcast) without proliferating boolean flags. Behavior for existing video and movie libraries is unchanged; audiobook and podcast modes will be consumed by the upcoming audiobook.go and podcast.go parsers in later tasks of this sub-plan. walkModeFor() derives the mode from a media_folders.type string; unknown types default to walkModeVideo to preserve prior behavior for any caller still passing a raw type. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(audiobooks): expose ffprobe format tags on ProbeData The audiobook scanner needs format-level tags (title, artist, album, date) for media_items metadata; ffprobe already parses them in ffprobeFormat.Tags but ProbeData previously discarded them. Add FormatTags map[string]string to ProbeData, populate it in convertProbeData via a new normalizeFormatTags helper that lowercases keys and trims values. Adds a fixture audiobook .m4b with embedded chapters (Intro/Outro) and format tags, and a test that verifies ProbeFile() returns both correctly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(audiobooks): parser for single-file audiobook folders parseAudiobookFolder reads tags + chapters via the existing ProbeFile (now that Task 5 exposes FormatTags on ProbeData) and produces a parsedAudiobook struct. Title falls back from "title" tag to "album"; author from "artist" -> "album_artist" -> "composer"; series from "album" -> "series" -> "mvnm" (Movement Name, used by some MP4 tools). Year parsed from "date" or "year" tags, tolerating ISO dates and parenthesized forms. Single-file case only; multi-file folders (one audio file per chapter) return a placeholder error and arrive in Task 7. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(audiobooks): multi-file audiobook folder support Folders containing N audio files (one per chapter/part) get one parsedAudiobookFile per file; each file's chapter list is synthesized as a single chapter with title = filename stem. Title/author/series/ year come from the first file's tags. Also drops the duplicate pickFirstNonEmpty helper added in Task 6 in favor of the existing firstNonEmpty already in probe.go. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(audiobooks): scanner write path produces audiobook media_items ScanAudiobookFolder walks an audiobooks-typed media folder and treats each immediate subdirectory as one audiobook. For each parsed audiobook it upserts: - one media_items row with type='audiobook' - one media_files row per audio file (with chapters JSONB) - author/narrator links in item_people (kind=7, kind=8) Adds itemRepo and personRepo to the Scanner struct, wired from fileRepo.Pool() in NewScanner — no constructor signature change needed. ScanFolder dispatches to this path when folder.Type='audiobooks', bypassing the per-file movie/TV pipeline because audiobooks are folder-scoped entities. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(audiobooks): filesystem podcast scanner ScanPodcastFolder walks a podcasts-typed media folder, treating each subdirectory as a podcast show and each audio file inside as an episode. Writes media_items.type='podcast' + episodes rows + media_files rows. RSS-subscribed feeds (podcast_feeds table) arrive in sub-plan 5; this task covers filesystem-only ingestion. ScanFolder dispatches to this path when folder.Type='podcasts'. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(audiobooks): implementation plan sub-plan 5 (podcasts) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(audiobooks): expose audiobooks/podcasts library types in admin UI Adds 'Audiobooks' and 'Podcasts' options to the library-type dropdown in the admin libraries page so operators can flag a folder as an audiobook or podcast library. Extends contentLevelsForType() so the admin UI's downstream filtering treats those types correctly (audiobook -> ['audiobook'], podcasts -> ['podcast', 'podcast_episode']). Backend scanner branches for these types were already wired in sub-plan 2. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(migrations): renumber 139_abs_sessions to 147 for origin/main merge origin/main adds 139_media_requests at the same number our local audiobook branch had used for abs_sessions. Renumber ours to 147 to free up 139 for the upstream migration. The schema_versions row is updated in lockstep on the running database so the migrator sees the abs_sessions migration as already applied at its new version. Migrations 140-146 (podcast feeds, media_folders kind noop, audiobook feature flag, abs playback sessions, podcast episode guid, audiobook series, audiobook title cleanup) stay where they are — they don't collide with anything on origin/main. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(migrations): renumber 140_podcast_feeds to 157 for origin/main merge origin/main added 140_user_permissions at the same version this branch had used for podcast_feeds. Renumber ours to 157 (next free above the collections-unify migration at 156) so 140 is free for the upstream migration. schema_versions on the running database is updated in lockstep so the migrator sees podcast_feeds as already applied at its new version. Same pattern asd59c1cb(renumber 139_abs_sessions to 147 for the prior main merge). Pending migrations after this rename: 132 (downloaded subtitles admin index, main), 140 (user_permissions, main), and 156 (unify_user_collections, this branch). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(migrations): renumber 141_media_folders_kind_noop to 159 for origin/main merge Same shape aseb8f67d(the 140→157 renumber from the previous main merge). origin/main added 141_episode_title_sort_index at the same version this branch had used for media_folders_kind_noop. Renumber ours to 159 (next free above the audiobook_series truncate at 158) so 141 is open for the upstream migration. schema_versions on the running database is updated in lockstep so the migrator sees media_folders_kind_noop as already applied at its new version. Pending migrations on silo-prod after this rename: 141 (episode_title_sort_index, main) and any other newer ones from main that the branch hasn't picked up yet. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(migrations): renumber 142_audiobooks_feature_flag to 160 for origin/main merge Companion to 3c6f062's 141 renumber — origin/main also added 142_episode_catalog_entries (alongside 141_episode_title_sort_index) at a version this branch had used for the audiobooks feature flag. Renumber ours to 160 so 142 is open for the upstream migration; schema_versions on silo-prod is updated in lockstep so the migrator sees audiobooks_feature_flag as already applied at its new version. This was the only remaining collision (verified by checking for duplicate version prefixes across migrations/). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(audiobooks): address foundation review comments * fix(audiobooks): tighten scanner identity handling * fix(audiobooks): propagate scanner cancellation * chore(audiobooks): adopt goose migration layout * docs(audiobooks): implementation plan sub-plan 3 (API + frontend MVP) Third of six sub-plans. 9 tasks: three REST endpoints (list/detail/ progress), TanStack Query hooks + types, three React pages (Library/Detail/Player), and navigation integration. Scoped to MVP — author/series indices, smart collections, share links, and other nice-to-haves from the spec are deferred. Streaming reuses silo's existing /api/v1/stream/{session_id}; no new transcode code. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(audiobooks): list endpoint at GET /api/v1/audiobooks Paginated list of media_items with type='audiobook' scoped to the caller's accessible libraries via the existing access filter. Mirrors silo's existing list-style handlers for movies and series. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(audiobooks): detail endpoint at GET /api/v1/audiobooks/{id} Returns the media_items row, its media_files (with chapters JSONB), author/narrator extracted from item_people (kinds 7/8), and the caller's per-profile listening progress from user_watch_progress. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(audiobooks): progress endpoint at POST /api/v1/audiobooks/{id}/progress UPSERTs user_watch_progress for the caller's (user_id, profile_id, content_id). Body carries position_seconds; clients are expected to post every 5-10s during playback plus on pause/seek (matching silo's existing video progress cadence). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(audiobooks): frontend types and TanStack Query hooks TypeScript types match the JSON shapes from the new /api/v1/audiobooks endpoints (list, detail, progress). Three hooks: useAudiobookLibrary (list), useAudiobook (detail), and useReportAudiobookProgress (mutation that invalidates the detail query on success so progress updates reflect immediately). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(audiobooks): library grid page at /audiobooks Renders a paginated grid of audiobook cards using the useAudiobookLibrary hook. Each card links to /audiobooks/book/{id}. Cards show poster, title, and year; falls back to a "No cover" placeholder when the audiobook has no poster_url. Empty state hints to operators that they need to set a library's type to 'audiobooks'. Routes themselves are wired in Task 8 (navigation integration). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(audiobooks): detail page with chapter list Renders cover, title, author, narrator, year, and overview alongside a chapter list. Clicking a chapter opens an inline sticky AudiobookPlayer at that chapter's start. A "Resume" button restarts playback at the saved progress position if present. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(audiobooks): HTML5 audio player with chapter navigation Single-file audiobook playback for MVP. Multi-file queuing arrives in a follow-up. Streams via the existing /api/v1/direct-download GET endpoint. Position is reported to /api/v1/audiobooks/{id}/progress every 10s while playing plus on pause/seek/end. Skip-30s, playback rate select, chapter list panel. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(audiobooks): wire navigation and routes Adds an Audiobooks entry to the sidebar and registers the two new routes (/audiobooks for the library grid, /audiobooks/book/:id for detail). The player renders inline inside the detail page; no dedicated player route is required for MVP. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(audiobooks): address native API review comments * feat(audiobooks): add ABS compatibility and polish * fix(audiobooks): stabilize ABS playback progress reporting * fix(audiobooks): clean up ABS branch review fixes * chore(audiobooks): adopt goose layout for ABS migrations * fix(audiobooks): align player seek bar props * feat(audiobooks): make libraries first-class catalog items * feat(admin): add server restart endpoint * fix(audiobooks): address review comment findings --------- Co-authored-by: RXWatcher <14085001+RXWatcher@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
co-authored by
RXWatcher
Claude Opus 4.7
parent
c469550fa0
commit
eb6024573e
@@ -0,0 +1,192 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { FolderPlus, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useCollections, useAddItemToCollection } from "@/hooks/queries/collections";
|
||||
import { useUserLibraries } from "@/hooks/queries/libraries";
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import { api } from "@/api/client";
|
||||
import { libraryCollectionKeys } from "@/hooks/queries/keys";
|
||||
import { useOptionalAuth } from "@/hooks/useAuth";
|
||||
import type { LibraryCollection } from "@/api/types";
|
||||
|
||||
interface AddToCollectionDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
mediaItemId: string;
|
||||
/** Optional label shown so the user knows what they're adding. */
|
||||
itemTitle?: string;
|
||||
}
|
||||
|
||||
interface CollectionPick {
|
||||
id: string;
|
||||
title: string;
|
||||
source: "user" | "library";
|
||||
group: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add-to-collection picker. Lists the caller's manual user collections
|
||||
* plus — for admins — every manual library collection grouped by library.
|
||||
* Synced/smart collections are excluded since they overwrite manual edits
|
||||
* on the next sync. Selecting a row commits the add via the existing
|
||||
* PUT collections/{id}/items/{itemId} endpoint (or the /admin variant).
|
||||
*/
|
||||
export default function AddToCollectionDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
mediaItemId,
|
||||
itemTitle,
|
||||
}: AddToCollectionDialogProps) {
|
||||
const auth = useOptionalAuth();
|
||||
const isAdmin = auth?.user?.role === "admin";
|
||||
const { data: userCollections, isLoading: userLoading } = useCollections();
|
||||
const { data: libraries } = useUserLibraries();
|
||||
const addItem = useAddItemToCollection();
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
// Admins fetch each library's collections so we can offer adding to admin
|
||||
// manual collections too. Non-admins skip these queries entirely.
|
||||
const libraryQueries = useQueries({
|
||||
queries: (isAdmin ? (libraries ?? []) : []).map((lib) => ({
|
||||
queryKey: libraryCollectionKeys.list(lib.id),
|
||||
queryFn: () =>
|
||||
api<{ collections: LibraryCollection[] }>(`/library/${lib.id}/collections`).then(
|
||||
(data) => data.collections ?? [],
|
||||
),
|
||||
enabled: Number.isFinite(lib.id) && lib.id > 0,
|
||||
})),
|
||||
});
|
||||
const libraryLoading = isAdmin && libraryQueries.some((q) => q.isLoading);
|
||||
|
||||
const picks = useMemo<CollectionPick[]>(() => {
|
||||
const out: CollectionPick[] = [];
|
||||
for (const c of userCollections ?? []) {
|
||||
if (c.collection_type === "manual") {
|
||||
out.push({ id: c.id, title: c.name, source: "user", group: "My Collections" });
|
||||
}
|
||||
}
|
||||
if (isAdmin && libraries) {
|
||||
for (let i = 0; i < libraries.length; i++) {
|
||||
const lib = libraries[i]!;
|
||||
const res = libraryQueries[i];
|
||||
if (res?.data) {
|
||||
for (const c of res.data) {
|
||||
if (c.collection_type === "manual") {
|
||||
out.push({ id: c.id, title: c.title, source: "library", group: lib.name });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}, [userCollections, libraries, libraryQueries, isAdmin]);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const m = new Map<string, CollectionPick[]>();
|
||||
for (const p of picks) {
|
||||
if (!m.has(p.group)) m.set(p.group, []);
|
||||
m.get(p.group)!.push(p);
|
||||
}
|
||||
return Array.from(m.entries());
|
||||
}, [picks]);
|
||||
|
||||
const isLoading = userLoading || libraryLoading;
|
||||
|
||||
function handleConfirm() {
|
||||
if (!selectedId) return;
|
||||
const pick = picks.find((p) => p.id === selectedId);
|
||||
if (!pick) return;
|
||||
addItem.mutate(
|
||||
{ collectionId: pick.id, mediaItemId, source: pick.source },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setSelectedId(null);
|
||||
onOpenChange(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add to Collection</DialogTitle>
|
||||
<DialogDescription>
|
||||
{itemTitle
|
||||
? `Pick a manual collection to add "${itemTitle}" to.`
|
||||
: "Pick a manual collection."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-80 overflow-y-auto rounded-md border">
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-10 text-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading collections…
|
||||
</div>
|
||||
) : groups.length === 0 ? (
|
||||
<div className="text-muted-foreground px-4 py-10 text-center text-sm">
|
||||
You don't have any manual collections yet. Create one in{" "}
|
||||
<span className="text-foreground">Collections</span> first.
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-border divide-y">
|
||||
{groups.map(([group, list]) => (
|
||||
<li key={group}>
|
||||
<div className="bg-muted/50 text-muted-foreground px-3 py-1.5 text-[11px] font-semibold tracking-[0.12em] uppercase">
|
||||
{group}
|
||||
</div>
|
||||
{list.map((p) => {
|
||||
const isSelected = p.id === selectedId;
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(p.id)}
|
||||
data-selected={isSelected ? "true" : undefined}
|
||||
className={`hover:bg-muted/50 flex w-full items-center gap-3 px-3 py-2.5 text-left text-sm transition-colors ${
|
||||
isSelected ? "bg-muted/60" : ""
|
||||
}`}
|
||||
>
|
||||
<FolderPlus className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
<span className="flex-1 truncate">{p.title}</span>
|
||||
{p.source === "library" && (
|
||||
<span className="text-muted-foreground text-[10px] tracking-[0.1em] uppercase">
|
||||
Library
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={addItem.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={!selectedId || addItem.isPending}
|
||||
className="gap-2"
|
||||
>
|
||||
{addItem.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
Add
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
PinOff,
|
||||
LayoutGrid,
|
||||
Puzzle,
|
||||
BookHeadphones,
|
||||
Send,
|
||||
} from "lucide-react";
|
||||
import { useTheme } from "@/hooks/useTheme";
|
||||
@@ -63,6 +64,9 @@ function getLibraryIcon(type: string) {
|
||||
return <Film className="h-[18px] w-[18px]" />;
|
||||
case "series":
|
||||
return <Tv className="h-[18px] w-[18px]" />;
|
||||
case "audiobook":
|
||||
case "audiobooks":
|
||||
return <BookHeadphones className="h-[18px] w-[18px]" />;
|
||||
default:
|
||||
return <Library className="h-[18px] w-[18px]" />;
|
||||
}
|
||||
|
||||
@@ -143,10 +143,19 @@ export default function ContinueWatchingCard(props: ContinueWatchingCardProps) {
|
||||
const containerWidth = isPoster
|
||||
? "w-[140px] shrink-0 sm:w-[160px] lg:w-[185px]"
|
||||
: "w-[260px] shrink-0 sm:w-[315px]";
|
||||
const imageAspect = isPoster ? "aspect-[2/3]" : "aspect-video";
|
||||
// Audiobook covers are square (Audible-style); use 1:1 for the poster
|
||||
// variant so they don't get top/bottom-cropped into a 2:3 frame.
|
||||
const imageAspect = isPoster
|
||||
? card.type === "audiobook"
|
||||
? "aspect-square"
|
||||
: "aspect-[2/3]"
|
||||
: "aspect-video";
|
||||
const isSectionEpisode = "sectionItem" in props && props.sectionItem?.type === "episode";
|
||||
// Section episode payloads use poster_url for vertical season/series artwork
|
||||
// and backdrop_url for the episode still used by wide cards.
|
||||
// Episodes store the horizontal still in poster_url (see
|
||||
// episode_catalog_source.go); wide-variant movies/series/seasons need the
|
||||
// backdrop for the 16:9 card. Poster variant always wants the vertical
|
||||
// poster, except section-episode payloads which use poster_url for the
|
||||
// vertical season/series artwork and backdrop_url for the episode still.
|
||||
const imagePrimary = isPoster
|
||||
? card.posterUrl
|
||||
: isSectionEpisode
|
||||
|
||||
@@ -154,7 +154,9 @@ export default function ItemCard({
|
||||
className="block overflow-hidden rounded-xl"
|
||||
>
|
||||
<div
|
||||
className="media-card-image relative aspect-[2/3]"
|
||||
className={`media-card-image relative ${
|
||||
item.type === "audiobook" ? "aspect-square" : "aspect-[2/3]"
|
||||
}`}
|
||||
style={
|
||||
thumbhashUrl
|
||||
? {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useOverlayPrefs } from "@/hooks/useOverlayPrefs";
|
||||
interface SharedItemGridProps {
|
||||
loading?: boolean;
|
||||
sortField?: string;
|
||||
libraryId?: number;
|
||||
selectionMode?: boolean;
|
||||
selectedIds?: ReadonlySet<string>;
|
||||
onToggleSelect?: (item: BrowseItem) => void;
|
||||
@@ -42,7 +43,14 @@ const GRID_CLASSES =
|
||||
"grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8 gap-3";
|
||||
|
||||
export default function ItemGrid(props: ItemGridProps) {
|
||||
const { loading, sortField, selectionMode = false, selectedIds, onToggleSelect } = props;
|
||||
const {
|
||||
loading,
|
||||
sortField,
|
||||
libraryId,
|
||||
selectionMode = false,
|
||||
selectedIds,
|
||||
onToggleSelect,
|
||||
} = props;
|
||||
const { prefs: overlayPrefs } = useOverlayPrefs();
|
||||
const totalItems = hasStaticItems(props) ? props.items.length : props.totalItems;
|
||||
const pages = hasStaticItems(props)
|
||||
@@ -147,6 +155,7 @@ export default function ItemGrid(props: ItemGridProps) {
|
||||
<div key={item.content_id} role="listitem">
|
||||
<ItemCard
|
||||
item={item}
|
||||
libraryId={libraryId}
|
||||
sortField={sortField}
|
||||
overlayPrefs={overlayPrefs}
|
||||
selectionMode={selectionMode}
|
||||
|
||||
@@ -104,13 +104,18 @@ function sortJobs(jobs: AdminJob[]) {
|
||||
const leftTime = Date.parse(left.requested_at);
|
||||
const rightTime = Date.parse(right.requested_at);
|
||||
if (Number.isNaN(leftTime) || Number.isNaN(rightTime)) {
|
||||
return right.id.localeCompare(left.id);
|
||||
const leftID = left.id ?? "";
|
||||
const rightID = right.id ?? "";
|
||||
return rightID.localeCompare(leftID);
|
||||
}
|
||||
return rightTime - leftTime;
|
||||
});
|
||||
}
|
||||
|
||||
function upsertJob(existing: AdminJob[] | undefined, nextJob: AdminJob, limit = 50) {
|
||||
if (!nextJob || !nextJob.id) {
|
||||
return existing ?? [];
|
||||
}
|
||||
const jobs = existing ? [...existing] : [];
|
||||
const index = jobs.findIndex((job) => job.id === nextJob.id);
|
||||
if (index >= 0) {
|
||||
|
||||
@@ -36,7 +36,9 @@ export default function SectionItemCard({ item, libraryId }: SectionItemCardProp
|
||||
<div className="relative">
|
||||
<ViewTransitionLink to={itemHref} className="block overflow-hidden rounded-xl">
|
||||
<div
|
||||
className="media-card-image relative aspect-[2/3]"
|
||||
className={`media-card-image relative ${
|
||||
item.type === "audiobook" ? "aspect-square" : "aspect-[2/3]"
|
||||
}`}
|
||||
style={
|
||||
thumbhashUrl
|
||||
? {
|
||||
|
||||
@@ -67,6 +67,10 @@ function contentLevelsForType(libraryType: string): string[] {
|
||||
return ["movie"];
|
||||
case "mixed":
|
||||
return ["movie", "series", "season", "episode"];
|
||||
case "audiobooks":
|
||||
return ["audiobook"];
|
||||
case "podcasts":
|
||||
return ["podcast", "podcast_episode"];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
@@ -131,7 +135,10 @@ function buildLevelChainsFromServer(
|
||||
}
|
||||
|
||||
function contentLevelLabel(level: string): string {
|
||||
return level.charAt(0).toUpperCase() + level.slice(1);
|
||||
return level
|
||||
.split("_")
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function buildProviderChainBody(activeLevelChains: Record<string, LevelChainItem[]>) {
|
||||
@@ -466,6 +473,8 @@ export function LibraryForm({
|
||||
<SelectItem value="movies">Movies</SelectItem>
|
||||
<SelectItem value="series">Series</SelectItem>
|
||||
<SelectItem value="mixed">Mixed</SelectItem>
|
||||
<SelectItem value="audiobooks">Audiobooks</SelectItem>
|
||||
<SelectItem value="podcasts">Podcasts</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -76,6 +76,7 @@ export default function CatalogFilterBar({
|
||||
<SelectItem value="movie">Movies</SelectItem>
|
||||
<SelectItem value="series">Series</SelectItem>
|
||||
<SelectItem value="episode">Episodes</SelectItem>
|
||||
<SelectItem value="audiobook">Audiobooks</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : null}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { GuidedFormState } from "@/components/collections/CollectionGuidedR
|
||||
import CollectionGuidedRulesEditor from "@/components/collections/CollectionGuidedRulesEditor";
|
||||
import CollectionRulesEditor from "@/components/collections/CollectionRulesEditor";
|
||||
import type { QuerySortRelevanceScope } from "@/lib/querySortOptions";
|
||||
import type { CatalogSearchState } from "@/pages/catalogSearchParams";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PortalContainerContext } from "@/components/ui/portal-container-context";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
@@ -33,6 +34,9 @@ const EMPTY_SECONDARY_FILTERS: Partial<GuidedFormState> = {
|
||||
director: "",
|
||||
writer: "",
|
||||
producer: "",
|
||||
author: "",
|
||||
narrator: "",
|
||||
series: "",
|
||||
studio: "",
|
||||
network: "",
|
||||
country: "",
|
||||
@@ -62,6 +66,10 @@ interface CatalogFilterSheetProps {
|
||||
onQueryDefinitionChange: (qd: QueryDefinition) => void;
|
||||
filters?: CatalogFiltersResponse;
|
||||
filtersLoading?: boolean;
|
||||
libraryType?: string;
|
||||
// Forwarded into the editor so audiobook-native facet sections can
|
||||
// typeahead-search /api/v1/catalog/filters/search at the same scope.
|
||||
catalogState?: CatalogSearchState;
|
||||
}
|
||||
|
||||
export default function CatalogFilterSheet({
|
||||
@@ -80,6 +88,8 @@ export default function CatalogFilterSheet({
|
||||
onQueryDefinitionChange,
|
||||
filters,
|
||||
filtersLoading = false,
|
||||
libraryType,
|
||||
catalogState,
|
||||
}: CatalogFilterSheetProps) {
|
||||
// Portal container inside the Sheet's DOM so that react-remove-scroll
|
||||
// (activated by the Dialog/Sheet) does not block scroll events inside
|
||||
@@ -152,6 +162,8 @@ export default function CatalogFilterSheet({
|
||||
showSortControls={false}
|
||||
filters={filters}
|
||||
filtersLoading={filtersLoading}
|
||||
libraryType={libraryType}
|
||||
catalogState={catalogState}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,9 @@ vi.mock("@/components/collections/CollectionGuidedRulesEditor", () => ({
|
||||
director: "",
|
||||
writer: "",
|
||||
producer: "",
|
||||
author: "",
|
||||
narrator: "",
|
||||
series: "",
|
||||
studio: "",
|
||||
network: "",
|
||||
country: "",
|
||||
@@ -133,6 +136,9 @@ describe("CatalogFiltersPanel", () => {
|
||||
director: "",
|
||||
writer: "",
|
||||
producer: "",
|
||||
author: "",
|
||||
narrator: "",
|
||||
series: "",
|
||||
studio: "",
|
||||
network: "",
|
||||
country: "",
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface CatalogFiltersPanelProps {
|
||||
sortRelevanceScope?: QuerySortRelevanceScope;
|
||||
resultCountLabel?: string;
|
||||
resultCountLoading?: boolean;
|
||||
libraryType?: string;
|
||||
}
|
||||
|
||||
export default function CatalogFiltersPanel({
|
||||
@@ -39,6 +40,7 @@ export default function CatalogFiltersPanel({
|
||||
sortRelevanceScope,
|
||||
resultCountLabel,
|
||||
resultCountLoading = false,
|
||||
libraryType,
|
||||
}: CatalogFiltersPanelProps) {
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [editorMode, setEditorMode] = useState<"guided" | "advanced">("guided");
|
||||
@@ -107,6 +109,7 @@ export default function CatalogFiltersPanel({
|
||||
onQueryDefinitionChange={(nextQd) =>
|
||||
onStateChange({ ...state, query_definition: nextQd })
|
||||
}
|
||||
libraryType={libraryType}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -129,6 +132,7 @@ export function CatalogFilterSheetContainer({
|
||||
onEditorModeChange,
|
||||
queryDefinition,
|
||||
onQueryDefinitionChange,
|
||||
libraryType,
|
||||
}: {
|
||||
state: CatalogSearchState;
|
||||
open: boolean;
|
||||
@@ -145,6 +149,7 @@ export function CatalogFilterSheetContainer({
|
||||
onEditorModeChange: (mode: "guided" | "advanced") => void;
|
||||
queryDefinition: QueryDefinition;
|
||||
onQueryDefinitionChange: (nextQd: QueryDefinition) => void;
|
||||
libraryType?: string;
|
||||
}) {
|
||||
const filtersQuery = useCatalogFilters(state, {
|
||||
enabled: editorMode === "guided",
|
||||
@@ -169,6 +174,8 @@ export function CatalogFilterSheetContainer({
|
||||
onQueryDefinitionChange={onQueryDefinitionChange}
|
||||
filters={filtersQuery.data}
|
||||
filtersLoading={filtersQuery.isLoading}
|
||||
libraryType={libraryType}
|
||||
catalogState={state}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -104,6 +104,30 @@ export function getActiveFilterBadges(state: GuidedFormState): ActiveFilterBadge
|
||||
});
|
||||
}
|
||||
|
||||
if (state.author) {
|
||||
badges.push({
|
||||
key: "author",
|
||||
label: `Author: ${state.author}`,
|
||||
clearPatch: { author: "" },
|
||||
});
|
||||
}
|
||||
|
||||
if (state.narrator) {
|
||||
badges.push({
|
||||
key: "narrator",
|
||||
label: `Narrator: ${state.narrator}`,
|
||||
clearPatch: { narrator: "" },
|
||||
});
|
||||
}
|
||||
|
||||
if (state.series) {
|
||||
badges.push({
|
||||
key: "series",
|
||||
label: `Series: ${state.series}`,
|
||||
clearPatch: { series: "" },
|
||||
});
|
||||
}
|
||||
|
||||
// Studio
|
||||
if (state.studio) {
|
||||
badges.push({
|
||||
|
||||
@@ -46,6 +46,9 @@ function emptyState(): GuidedFormState {
|
||||
director: "",
|
||||
writer: "",
|
||||
producer: "",
|
||||
author: "",
|
||||
narrator: "",
|
||||
series: "",
|
||||
studio: "",
|
||||
network: "",
|
||||
country: "",
|
||||
@@ -150,6 +153,9 @@ describe("guidedStateToQueryDefinition", () => {
|
||||
director: "Denis Villeneuve",
|
||||
writer: "Tony Gilroy",
|
||||
producer: "Emma Thomas",
|
||||
author: "",
|
||||
narrator: "",
|
||||
series: "",
|
||||
studio: "",
|
||||
originalLanguages: ["Japanese"],
|
||||
network: "HBO",
|
||||
@@ -259,6 +265,36 @@ describe("guidedStateToQueryDefinition", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("round-trips audiobook-native fields (author/narrator/series)", () => {
|
||||
const original: QueryDefinition = {
|
||||
library_ids: [9],
|
||||
media_scope: "audiobook",
|
||||
match: "all",
|
||||
groups: [
|
||||
{
|
||||
match: "all",
|
||||
rules: [
|
||||
{ field: "author", op: "is", value: "Brandon Sanderson" },
|
||||
{ field: "narrator", op: "is", value: "Michael Kramer" },
|
||||
{ field: "series", op: "is", value: "Mistborn" },
|
||||
],
|
||||
},
|
||||
],
|
||||
sort: { field: "title", order: "asc" },
|
||||
};
|
||||
|
||||
const state = queryDefinitionToGuidedState(original);
|
||||
expect(state.author).toBe("Brandon Sanderson");
|
||||
expect(state.narrator).toBe("Michael Kramer");
|
||||
expect(state.series).toBe("Mistborn");
|
||||
|
||||
const rebuilt = guidedStateToQueryDefinition(state, original);
|
||||
const rules = rebuilt.groups[0]!.rules;
|
||||
expect(rules).toContainEqual({ field: "author", op: "is", value: "Brandon Sanderson" });
|
||||
expect(rules).toContainEqual({ field: "narrator", op: "is", value: "Michael Kramer" });
|
||||
expect(rules).toContainEqual({ field: "series", op: "is", value: "Mistborn" });
|
||||
});
|
||||
|
||||
it("round-trips last_air_date sort through parse and serialize", () => {
|
||||
const original: QueryDefinition = {
|
||||
library_ids: [1],
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "@/api/types";
|
||||
import LibraryMultiSelect from "@/components/LibraryMultiSelect";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FacetSearchSelect } from "@/components/ui/facet-search-select";
|
||||
import { PersonSearchSelect } from "@/components/ui/person-search-select";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useCatalogMetadataFilters } from "@/hooks/queries/catalog";
|
||||
import type { CatalogSearchState } from "@/pages/catalogSearchParams";
|
||||
import {
|
||||
getDefaultQuerySortOrder,
|
||||
normalizeQuerySortForScope,
|
||||
@@ -38,7 +40,7 @@ const DECADE_OPTIONS = Array.from({ length: 15 }, (_, index) => 2030 - index * 1
|
||||
|
||||
/** Flat form state that maps 1-to-1 with friendly form fields. */
|
||||
export interface GuidedFormState {
|
||||
mediaScope: "all" | "movie" | "series" | "episode";
|
||||
mediaScope: "all" | "movie" | "series" | "episode" | "audiobook";
|
||||
libraryIds: number[];
|
||||
genres: string[];
|
||||
decade: string;
|
||||
@@ -51,6 +53,9 @@ export interface GuidedFormState {
|
||||
director: string;
|
||||
writer: string;
|
||||
producer: string;
|
||||
author: string;
|
||||
narrator: string;
|
||||
series: string;
|
||||
studio: string;
|
||||
network: string;
|
||||
country: string;
|
||||
@@ -103,6 +108,9 @@ export function queryDefinitionToGuidedState(
|
||||
director: "",
|
||||
writer: "",
|
||||
producer: "",
|
||||
author: "",
|
||||
narrator: "",
|
||||
series: "",
|
||||
studio: "",
|
||||
network: "",
|
||||
country: "",
|
||||
@@ -177,6 +185,15 @@ export function queryDefinitionToGuidedState(
|
||||
case "producer":
|
||||
if (rule.op === "is") state.producer = String(rule.value);
|
||||
break;
|
||||
case "author":
|
||||
if (rule.op === "is") state.author = String(rule.value);
|
||||
break;
|
||||
case "narrator":
|
||||
if (rule.op === "is") state.narrator = String(rule.value);
|
||||
break;
|
||||
case "series":
|
||||
if (rule.op === "is") state.series = String(rule.value);
|
||||
break;
|
||||
case "studio":
|
||||
if (rule.op === "is") state.studio = String(rule.value);
|
||||
break;
|
||||
@@ -278,6 +295,15 @@ export function guidedStateToQueryDefinition(
|
||||
if (state.producer) {
|
||||
rules.push({ field: "producer", op: "is", value: state.producer });
|
||||
}
|
||||
if (state.author) {
|
||||
rules.push({ field: "author", op: "is", value: state.author });
|
||||
}
|
||||
if (state.narrator) {
|
||||
rules.push({ field: "narrator", op: "is", value: state.narrator });
|
||||
}
|
||||
if (state.series) {
|
||||
rules.push({ field: "series", op: "is", value: state.series });
|
||||
}
|
||||
if (state.studio) {
|
||||
rules.push({ field: "studio", op: "is", value: state.studio });
|
||||
}
|
||||
@@ -358,6 +384,16 @@ interface CollectionGuidedRulesEditorProps {
|
||||
showSortControls?: boolean;
|
||||
filters?: CatalogFiltersResponse;
|
||||
filtersLoading?: boolean;
|
||||
// When the editor is scoped to a single library (e.g. on the library
|
||||
// browse page) pass its type so video-only filter sections (Director,
|
||||
// Studio, Network, Content Rating, Video Quality, etc.) can be hidden
|
||||
// for audiobook libraries.
|
||||
libraryType?: string;
|
||||
// When set, the audiobook-native facet sections (Author / Narrator /
|
||||
// Series) switch to typeahead-backed FacetSearchSelect, querying
|
||||
// /api/v1/catalog/filters/search scoped to this state. Without it
|
||||
// they fall back to the bulk filters payload (top 1000 alphabetical).
|
||||
catalogState?: CatalogSearchState;
|
||||
}
|
||||
|
||||
export default function CollectionGuidedRulesEditor({
|
||||
@@ -373,11 +409,17 @@ export default function CollectionGuidedRulesEditor({
|
||||
showSortControls = true,
|
||||
filters: providedFilters,
|
||||
filtersLoading: providedFiltersLoading,
|
||||
libraryType,
|
||||
catalogState,
|
||||
}: CollectionGuidedRulesEditorProps) {
|
||||
const state = useMemo(() => queryDefinitionToGuidedState(value), [value]);
|
||||
const metadataFiltersQuery = useCatalogMetadataFilters();
|
||||
const filters = providedFilters ?? metadataFiltersQuery.data;
|
||||
const filtersLoading = providedFiltersLoading ?? metadataFiltersQuery.isLoading;
|
||||
// Audiobook-only libraries should hide video-only filter sections.
|
||||
// Backend stores type as the plural "audiobooks"; the singular form
|
||||
// shows up in some API surfaces — accept either.
|
||||
const isAudiobookLibrary = libraryType === "audiobook" || libraryType === "audiobooks";
|
||||
const sortOptions = getCollectionSortOptions(allowPersonalizedSorts, sortRelevanceScope);
|
||||
const selectedSort = normalizeQuerySortForScope(
|
||||
{ field: state.sortField, order: state.sortOrder },
|
||||
@@ -518,32 +560,34 @@ export default function CollectionGuidedRulesEditor({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Minimum IMDb Rating</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={10}
|
||||
step={0.1}
|
||||
value={state.minRating}
|
||||
onChange={(e) => update({ minRating: e.target.value })}
|
||||
placeholder="e.g. 7.0"
|
||||
disabled={readOnly}
|
||||
/>
|
||||
{isAudiobookLibrary ? null : (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Minimum IMDb Rating</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={10}
|
||||
step={0.1}
|
||||
value={state.minRating}
|
||||
onChange={(e) => update({ minRating: e.target.value })}
|
||||
placeholder="e.g. 7.0"
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Content Rating</Label>
|
||||
<SearchableSelect
|
||||
options={filters?.content_ratings ?? []}
|
||||
value={state.contentRating}
|
||||
onChange={(contentRating) => update({ contentRating })}
|
||||
placeholder="Select rating..."
|
||||
disabled={readOnly}
|
||||
isLoading={filtersLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Content Rating</Label>
|
||||
<SearchableSelect
|
||||
options={filters?.content_ratings ?? []}
|
||||
value={state.contentRating}
|
||||
onChange={(contentRating) => update({ contentRating })}
|
||||
placeholder="Select rating..."
|
||||
disabled={readOnly}
|
||||
isLoading={filtersLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Original Language</Label>
|
||||
@@ -558,68 +602,147 @@ export default function CollectionGuidedRulesEditor({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Actor</Label>
|
||||
<PersonSearchSelect
|
||||
value={state.actor}
|
||||
onChange={(actor) => update({ actor })}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Director</Label>
|
||||
<PersonSearchSelect
|
||||
value={state.director}
|
||||
onChange={(director) => update({ director })}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{isAudiobookLibrary ? null : (
|
||||
<>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Actor</Label>
|
||||
<PersonSearchSelect
|
||||
value={state.actor}
|
||||
onChange={(actor) => update({ actor })}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Director</Label>
|
||||
<PersonSearchSelect
|
||||
value={state.director}
|
||||
onChange={(director) => update({ director })}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Writer</Label>
|
||||
<PersonSearchSelect
|
||||
value={state.writer}
|
||||
onChange={(writer) => update({ writer })}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Producer</Label>
|
||||
<PersonSearchSelect
|
||||
value={state.producer}
|
||||
onChange={(producer) => update({ producer })}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Writer</Label>
|
||||
<PersonSearchSelect
|
||||
value={state.writer}
|
||||
onChange={(writer) => update({ writer })}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Producer</Label>
|
||||
<PersonSearchSelect
|
||||
value={state.producer}
|
||||
onChange={(producer) => update({ producer })}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Studio</Label>
|
||||
<SearchableSelect
|
||||
options={filters?.studios ?? []}
|
||||
value={state.studio}
|
||||
onChange={(studio) => update({ studio })}
|
||||
placeholder="Select studio..."
|
||||
disabled={readOnly}
|
||||
isLoading={filtersLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Network</Label>
|
||||
<SearchableSelect
|
||||
options={filters?.networks ?? []}
|
||||
value={state.network}
|
||||
onChange={(network) => update({ network })}
|
||||
placeholder="Select network..."
|
||||
disabled={readOnly}
|
||||
isLoading={filtersLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Studio</Label>
|
||||
<SearchableSelect
|
||||
options={filters?.studios ?? []}
|
||||
value={state.studio}
|
||||
onChange={(studio) => update({ studio })}
|
||||
placeholder="Select studio..."
|
||||
disabled={readOnly}
|
||||
isLoading={filtersLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Network</Label>
|
||||
<SearchableSelect
|
||||
options={filters?.networks ?? []}
|
||||
value={state.network}
|
||||
onChange={(network) => update({ network })}
|
||||
placeholder="Select network..."
|
||||
disabled={readOnly}
|
||||
isLoading={filtersLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isAudiobookLibrary ? (
|
||||
<>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Author</Label>
|
||||
{catalogState ? (
|
||||
<FacetSearchSelect
|
||||
facet="author"
|
||||
state={catalogState}
|
||||
value={state.author}
|
||||
onChange={(author) => update({ author })}
|
||||
placeholder="Search authors..."
|
||||
disabled={readOnly}
|
||||
/>
|
||||
) : (
|
||||
<SearchableSelect
|
||||
options={filters?.authors ?? []}
|
||||
value={state.author}
|
||||
onChange={(author) => update({ author })}
|
||||
placeholder="Select author..."
|
||||
disabled={readOnly}
|
||||
isLoading={filtersLoading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Narrator</Label>
|
||||
{catalogState ? (
|
||||
<FacetSearchSelect
|
||||
facet="narrator"
|
||||
state={catalogState}
|
||||
value={state.narrator}
|
||||
onChange={(narrator) => update({ narrator })}
|
||||
placeholder="Search narrators..."
|
||||
disabled={readOnly}
|
||||
/>
|
||||
) : (
|
||||
<SearchableSelect
|
||||
options={filters?.narrators ?? []}
|
||||
value={state.narrator}
|
||||
onChange={(narrator) => update({ narrator })}
|
||||
placeholder="Select narrator..."
|
||||
disabled={readOnly}
|
||||
isLoading={filtersLoading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Series</Label>
|
||||
{catalogState ? (
|
||||
<FacetSearchSelect
|
||||
facet="series"
|
||||
state={catalogState}
|
||||
value={state.series}
|
||||
onChange={(series) => update({ series })}
|
||||
placeholder="Search series..."
|
||||
disabled={readOnly}
|
||||
/>
|
||||
) : (
|
||||
<SearchableSelect
|
||||
options={filters?.series ?? []}
|
||||
value={state.series}
|
||||
onChange={(series) => update({ series })}
|
||||
placeholder="Select series..."
|
||||
disabled={readOnly}
|
||||
isLoading={filtersLoading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
@@ -681,34 +804,36 @@ export default function CollectionGuidedRulesEditor({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Video Quality</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{ key: "fourK", label: "4K" },
|
||||
{ key: "hdr", label: "HDR" },
|
||||
{ key: "dolbyVision", label: "DOVI" },
|
||||
].map((option) => {
|
||||
const selected = state[option.key as keyof GuidedFormState] === true;
|
||||
return (
|
||||
<Button
|
||||
key={option.key}
|
||||
type="button"
|
||||
variant={selected ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
update({
|
||||
[option.key]: !selected,
|
||||
} as Partial<GuidedFormState>)
|
||||
}
|
||||
disabled={readOnly}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
{isAudiobookLibrary ? null : (
|
||||
<div className="space-y-2">
|
||||
<Label>Video Quality</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{ key: "fourK", label: "4K" },
|
||||
{ key: "hdr", label: "HDR" },
|
||||
{ key: "dolbyVision", label: "DOVI" },
|
||||
].map((option) => {
|
||||
const selected = state[option.key as keyof GuidedFormState] === true;
|
||||
return (
|
||||
<Button
|
||||
key={option.key}
|
||||
type="button"
|
||||
variant={selected ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
update({
|
||||
[option.key]: !selected,
|
||||
} as Partial<GuidedFormState>)
|
||||
}
|
||||
disabled={readOnly}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -68,7 +68,9 @@ export default function CollectionRulesEditor({
|
||||
onChange({
|
||||
...value,
|
||||
media_scope:
|
||||
next === "all" ? undefined : (next as "movie" | "series" | "episode"),
|
||||
next === "all"
|
||||
? undefined
|
||||
: (next as "movie" | "series" | "episode" | "audiobook"),
|
||||
sort: nextSort,
|
||||
});
|
||||
}}
|
||||
@@ -82,6 +84,7 @@ export default function CollectionRulesEditor({
|
||||
<SelectItem value="movie">Movies</SelectItem>
|
||||
<SelectItem value="series">Series</SelectItem>
|
||||
<SelectItem value="episode">Episodes</SelectItem>
|
||||
<SelectItem value="audiobook">Audiobooks</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
import { GripVertical, Trash2 } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { GripVertical, Loader2, Plus, Search, Trash2 } from "lucide-react";
|
||||
|
||||
import { DndContext } from "@dnd-kit/core";
|
||||
import {
|
||||
@@ -9,29 +10,149 @@ import {
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
import type { CollectionItem } from "@/api/types";
|
||||
import type { BrowseItem, CollectionItem } from "@/api/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
useAddItemToCollection,
|
||||
useCollectionItems,
|
||||
useRemoveCollectionItem,
|
||||
useReorderCollectionItems,
|
||||
} from "@/hooks/queries/collections";
|
||||
import { createCatalogSearchState, fetchCatalogPage } from "@/hooks/queries/catalog";
|
||||
import { catalogKeys } from "@/hooks/queries/keys";
|
||||
import { useSortableList } from "@/hooks/useSortableList";
|
||||
import { useDebounce } from "@/hooks/useDebounce";
|
||||
|
||||
interface ManualCollectionItemsEditorProps {
|
||||
collectionId: string;
|
||||
readOnly?: boolean;
|
||||
/**
|
||||
* Whether this collection lives under the admin /admin/collections/* route
|
||||
* (library collection) or the user /collections/* route (personal). The
|
||||
* add-item mutation needs to know to hit the right endpoint.
|
||||
*/
|
||||
source?: "user" | "library";
|
||||
}
|
||||
|
||||
const SEARCH_LIMIT = 12;
|
||||
const DEBOUNCE_MS = 250;
|
||||
|
||||
function AddItemPanel({
|
||||
collectionId,
|
||||
source,
|
||||
existingIds,
|
||||
}: {
|
||||
collectionId: string;
|
||||
source: "user" | "library";
|
||||
existingIds: Set<string>;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const debounced = useDebounce(query.trim(), DEBOUNCE_MS);
|
||||
const addItem = useAddItemToCollection();
|
||||
|
||||
const searchState = useMemo(
|
||||
() => createCatalogSearchState("query", { q: debounced || undefined }),
|
||||
[debounced],
|
||||
);
|
||||
|
||||
const results = useQuery({
|
||||
queryKey: [
|
||||
"manualCollectionPicker",
|
||||
collectionId,
|
||||
catalogKeys.list({
|
||||
source: searchState.source,
|
||||
q: searchState.q,
|
||||
limit: SEARCH_LIMIT,
|
||||
offset: 0,
|
||||
}),
|
||||
],
|
||||
queryFn: ({ signal }) => fetchCatalogPage(searchState, SEARCH_LIMIT, 0, { signal }),
|
||||
enabled: debounced.length > 0,
|
||||
staleTime: 30 * 1000,
|
||||
});
|
||||
|
||||
const items: BrowseItem[] = results.data?.items ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search the catalog to add titles…"
|
||||
className="pl-9"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
{debounced.length === 0 ? null : results.isLoading ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2 px-3 py-3 text-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Searching…
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="text-muted-foreground border-border/60 rounded-md border border-dashed px-3 py-3 text-sm">
|
||||
No matches.
|
||||
</div>
|
||||
) : (
|
||||
<ul className="border-border/60 divide-border/60 max-h-72 divide-y overflow-y-auto rounded-md border">
|
||||
{items.map((item) => {
|
||||
const already = existingIds.has(item.content_id);
|
||||
return (
|
||||
<li key={item.content_id} className="flex items-center gap-3 px-3 py-2">
|
||||
{item.poster_url ? (
|
||||
<img
|
||||
src={item.poster_url}
|
||||
alt=""
|
||||
className="bg-muted h-10 w-10 shrink-0 rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-muted h-10 w-10 shrink-0 rounded" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{item.title}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{item.year ? `${item.year} · ` : ""}
|
||||
{item.type}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={already ? "ghost" : "outline"}
|
||||
disabled={already || addItem.isPending}
|
||||
onClick={() =>
|
||||
addItem.mutate({
|
||||
collectionId,
|
||||
mediaItemId: item.content_id,
|
||||
source,
|
||||
})
|
||||
}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{already ? "Added" : "Add"}
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ManualCollectionItemsEditor({
|
||||
collectionId,
|
||||
readOnly = false,
|
||||
source = "user",
|
||||
}: ManualCollectionItemsEditorProps) {
|
||||
const { data, isLoading } = useCollectionItems(collectionId);
|
||||
const items = useMemo(() => data ?? [], [data]);
|
||||
const reorderMutation = useReorderCollectionItems(collectionId);
|
||||
const removeMutation = useRemoveCollectionItem(collectionId);
|
||||
const existingIds = useMemo(() => new Set(items.map((i) => i.media_item_id)), [items]);
|
||||
|
||||
const { sensors, collisionDetection, handleDragEnd } = useSortableList(
|
||||
items,
|
||||
@@ -49,37 +170,40 @@ export function ManualCollectionItemsEditor({
|
||||
);
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="text-muted-foreground rounded-lg border border-dashed px-4 py-5 text-sm">
|
||||
No items yet. Items added to this collection will appear here.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={collisionDetection}
|
||||
onDragEnd={readOnly ? undefined : handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={items.map((item) => item.media_item_id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{items.map((item, index) => (
|
||||
<SortableItemRow
|
||||
key={item.media_item_id}
|
||||
item={item}
|
||||
index={index}
|
||||
readOnly={readOnly}
|
||||
onRemove={() => removeMutation.mutate(item.media_item_id)}
|
||||
/>
|
||||
))}
|
||||
<div className="space-y-4">
|
||||
{!readOnly && (
|
||||
<AddItemPanel collectionId={collectionId} source={source} existingIds={existingIds} />
|
||||
)}
|
||||
{items.length === 0 ? (
|
||||
<div className="text-muted-foreground rounded-lg border border-dashed px-4 py-5 text-sm">
|
||||
No items yet. Search above to add titles.
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={collisionDetection}
|
||||
onDragEnd={readOnly ? undefined : handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={items.map((item) => item.media_item_id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{items.map((item, index) => (
|
||||
<SortableItemRow
|
||||
key={item.media_item_id}
|
||||
item={item}
|
||||
index={index}
|
||||
readOnly={readOnly}
|
||||
onRemove={() => removeMutation.mutate(item.media_item_id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -61,8 +61,9 @@ export function SectionSummaryBadges({
|
||||
<Badge variant="secondary">{recipeLabel(catalog, section.sectionType)}</Badge>
|
||||
{queryDefinition.media_scope === "movie" ? <Badge variant="outline">Movies</Badge> : null}
|
||||
{queryDefinition.media_scope === "series" ? <Badge variant="outline">Series</Badge> : null}
|
||||
{queryDefinition.media_scope === "episode" ? (
|
||||
<Badge variant="outline">Episodes</Badge>
|
||||
{queryDefinition.media_scope === "episode" ? <Badge variant="outline">Episodes</Badge> : null}
|
||||
{queryDefinition.media_scope === "audiobook" ? (
|
||||
<Badge variant="outline">Audiobooks</Badge>
|
||||
) : null}
|
||||
{libraries
|
||||
? queryDefinition.library_ids.map((libraryId) => {
|
||||
|
||||
@@ -477,7 +477,9 @@ export default function SectionEditorDrawer(props: SectionEditorDrawerProps) {
|
||||
setQueryDefinition({
|
||||
...queryDefinition,
|
||||
media_scope:
|
||||
value === "all" ? undefined : (value as "movie" | "series" | "episode"),
|
||||
value === "all"
|
||||
? undefined
|
||||
: (value as "movie" | "series" | "episode" | "audiobook"),
|
||||
})
|
||||
}
|
||||
>
|
||||
@@ -489,6 +491,7 @@ export default function SectionEditorDrawer(props: SectionEditorDrawerProps) {
|
||||
<SelectItem value="movie">Movies</SelectItem>
|
||||
<SelectItem value="series">Series</SelectItem>
|
||||
<SelectItem value="episode">Episodes</SelectItem>
|
||||
<SelectItem value="audiobook">Audiobooks</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import * as React from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Popover as PopoverPrimitive } from "radix-ui";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
|
||||
import { fetchCatalogFacetSearch, type CatalogFacetName } from "@/hooks/queries/catalog";
|
||||
import { useDebounce } from "@/hooks/useDebounce";
|
||||
import type { CatalogSearchState } from "@/pages/catalogSearchParams";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PortalContainerContext } from "./portal-container-context";
|
||||
|
||||
interface FacetSearchSelectProps {
|
||||
/** The catalog facet to typeahead-search (e.g. "author", "series"). */
|
||||
facet: CatalogFacetName;
|
||||
/** The catalog scope — usually the same state passed to CatalogFiltersPanel. */
|
||||
state: CatalogSearchState;
|
||||
/** Currently selected value (or empty for "any"). */
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
/** Max matches to fetch per query. Defaults to 20. */
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* FacetSearchSelect is the typeahead counterpart to SearchableSelect for
|
||||
* high-cardinality catalog facets (Author, Narrator, Series). It debounces
|
||||
* the user's input then queries /api/v1/catalog/filters/search with the
|
||||
* given facet + prefix. The dropdown lists matches in alphabetical order
|
||||
* with the currently selected value pinned first so users can clear it
|
||||
* without re-searching.
|
||||
*
|
||||
* The component intentionally does not render an "All N items" indicator —
|
||||
* the typeahead pattern relies on the user narrowing rather than scrolling.
|
||||
* When more matches are available than were returned, a small "Keep typing
|
||||
* to narrow further" hint shows below the list.
|
||||
*/
|
||||
export function FacetSearchSelect({
|
||||
facet,
|
||||
state,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Search...",
|
||||
disabled = false,
|
||||
limit = 20,
|
||||
}: FacetSearchSelectProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [focusedIndex, setFocusedIndex] = React.useState(-1);
|
||||
const listboxId = React.useId();
|
||||
const portalContainer = React.useContext(PortalContainerContext);
|
||||
const debouncedQuery = useDebounce(search.trim(), 200);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["catalogFacetSearch", facet, debouncedQuery, limit, state] as const,
|
||||
queryFn: ({ signal }) =>
|
||||
fetchCatalogFacetSearch(state, facet, debouncedQuery, limit, { signal }),
|
||||
enabled: open && debouncedQuery.length > 0,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const matches = query.data?.matches ?? [];
|
||||
const hasMore = query.data?.has_more ?? false;
|
||||
|
||||
const options = React.useMemo(() => {
|
||||
const names = new Set<string>();
|
||||
if (value) {
|
||||
names.add(value);
|
||||
}
|
||||
for (const name of matches) {
|
||||
if (name) {
|
||||
names.add(name);
|
||||
}
|
||||
}
|
||||
return Array.from(names);
|
||||
}, [matches, value]);
|
||||
|
||||
const allItems = React.useMemo(() => ["", ...options], [options]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
setFocusedIndex(-1);
|
||||
setSearch("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
function handleSearchKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setFocusedIndex((prev) => (prev < allItems.length - 1 ? prev + 1 : prev));
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setFocusedIndex((prev) => (prev > 0 ? prev - 1 : prev));
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
if (focusedIndex >= 0 && focusedIndex < allItems.length) {
|
||||
const nextValue = allItems[focusedIndex];
|
||||
if (nextValue !== undefined) {
|
||||
onChange(nextValue);
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PopoverPrimitive.Root open={open} onOpenChange={setOpen} modal={false}>
|
||||
<PopoverPrimitive.Trigger asChild disabled={disabled}>
|
||||
<button
|
||||
type="button"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-controls={listboxId}
|
||||
className={cn(
|
||||
"border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-9 w-full items-center justify-between rounded-md border px-3 py-2 text-sm shadow-xs focus:ring-1 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
!value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{value || placeholder}</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</button>
|
||||
</PopoverPrimitive.Trigger>
|
||||
|
||||
<PopoverPrimitive.Portal container={portalContainer ?? undefined}>
|
||||
<PopoverPrimitive.Content
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
collisionPadding={8}
|
||||
className="bg-popover text-popover-foreground z-50 w-[var(--radix-popover-trigger-width)] rounded-md border shadow-md"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<div className="p-2">
|
||||
<input
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
setSearch(event.target.value);
|
||||
setFocusedIndex(-1);
|
||||
}}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
placeholder="Type to search..."
|
||||
aria-label="Search"
|
||||
aria-controls={listboxId}
|
||||
aria-activedescendant={
|
||||
focusedIndex >= 0 ? `${listboxId}-opt-${focusedIndex}` : undefined
|
||||
}
|
||||
className="border-input bg-background placeholder:text-muted-foreground flex h-8 w-full rounded-md border px-2 text-sm outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id={listboxId}
|
||||
role="listbox"
|
||||
className="max-h-60 overflow-y-auto overscroll-contain p-1"
|
||||
>
|
||||
{query.isLoading ? (
|
||||
<p className="text-muted-foreground py-4 text-center text-sm">Loading...</p>
|
||||
) : options.length === 0 ? (
|
||||
<p className="text-muted-foreground py-4 text-center text-sm">
|
||||
{debouncedQuery ? "No matches" : "Start typing to search"}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
id={`${listboxId}-opt-0`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={!value}
|
||||
className={cn(
|
||||
"hover:bg-accent hover:text-accent-foreground relative flex w-full cursor-pointer items-center rounded-sm px-2 py-1.5 text-sm select-none",
|
||||
!value && "font-medium",
|
||||
focusedIndex === 0 && "bg-accent text-accent-foreground",
|
||||
)}
|
||||
onClick={() => {
|
||||
onChange("");
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn("mr-2 h-4 w-4 shrink-0", value ? "opacity-0" : "opacity-100")}
|
||||
/>
|
||||
<span className="text-muted-foreground italic">Any</span>
|
||||
</button>
|
||||
|
||||
{options.map((option, index) => (
|
||||
<button
|
||||
id={`${listboxId}-opt-${index + 1}`}
|
||||
key={option}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={value === option}
|
||||
className={cn(
|
||||
"hover:bg-accent hover:text-accent-foreground relative flex w-full cursor-pointer items-center rounded-sm px-2 py-1.5 text-sm select-none",
|
||||
value === option && "font-medium",
|
||||
focusedIndex === index + 1 && "bg-accent text-accent-foreground",
|
||||
)}
|
||||
onClick={() => {
|
||||
onChange(option);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4 shrink-0",
|
||||
value === option ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
{hasMore ? (
|
||||
<p className="text-muted-foreground px-2 py-1.5 text-xs italic">
|
||||
Keep typing to narrow further...
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PopoverPrimitive.Content>
|
||||
</PopoverPrimitive.Portal>
|
||||
</PopoverPrimitive.Root>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user