From b9a9613fd02463ad94e123954dba353701430170 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:51:38 -0400 Subject: [PATCH] feat(web): redesign library create/edit dialog with sectioned navigation Replace the single scrolling library form dialog with a Plex-style editor: a left-hand navigation rail with General, Folders, Metadata, and Advanced panes, inline validation that jumps to the offending section, a visual icon-card library type picker, and a sticky footer with Cancel/Save actions. Form state, provider-chain logic, and save mutations move into a shared useLibraryForm hook with reusable field-group components, so the setup wizard keeps its inline layout without duplicating logic. LibraryPosterSection moves out of AdminLibraries.tsx into its own component, and the Advanced toggle cards now use semantic theme tokens instead of hardcoded white overlays that broke light themes. Co-Authored-By: Claude Fable 5 --- .../admin/libraries/LibraryEditorDialog.tsx | 226 +++++++ .../admin/libraries/LibraryForm.tsx | 567 +----------------- .../admin/libraries/LibraryFormSections.tsx | 359 +++++++++++ .../admin/libraries/LibraryPosterSection.tsx | 73 +++ .../admin/libraries/libraryTypes.ts | 13 + .../admin/libraries/useLibraryForm.ts | 358 +++++++++++ web/src/pages/AdminLibraries.tsx | 126 +--- 7 files changed, 1080 insertions(+), 642 deletions(-) create mode 100644 web/src/components/admin/libraries/LibraryEditorDialog.tsx create mode 100644 web/src/components/admin/libraries/LibraryFormSections.tsx create mode 100644 web/src/components/admin/libraries/LibraryPosterSection.tsx create mode 100644 web/src/components/admin/libraries/libraryTypes.ts create mode 100644 web/src/components/admin/libraries/useLibraryForm.ts diff --git a/web/src/components/admin/libraries/LibraryEditorDialog.tsx b/web/src/components/admin/libraries/LibraryEditorDialog.tsx new file mode 100644 index 00000000..825b4038 --- /dev/null +++ b/web/src/components/admin/libraries/LibraryEditorDialog.tsx @@ -0,0 +1,226 @@ +import { useState } from "react"; +import type { FormEvent } from "react"; +import { Database, FolderOpen, Settings2, SlidersHorizontal } from "lucide-react"; + +import type { Library } from "@/api/types"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { cn } from "@/lib/utils"; + +import { AdvancedFields, FolderFields, GeneralFields, MetadataFields } from "./LibraryFormSections"; +import { libraryTypeMeta } from "./libraryTypes"; +import { LibraryPosterSection } from "./LibraryPosterSection"; +import { useLibraryForm } from "./useLibraryForm"; +import type { LibraryFormErrors } from "./useLibraryForm"; + +type SectionId = "general" | "folders" | "metadata" | "advanced"; + +const SECTIONS: Array<{ + id: SectionId; + label: string; + icon: typeof Settings2; + title: string; + description: string; +}> = [ + { + id: "general", + label: "General", + icon: Settings2, + title: "General", + description: "Name this library and choose the kind of media it holds.", + }, + { + id: "folders", + label: "Folders", + icon: FolderOpen, + title: "Folders", + description: "Silo scans these folders for media and watches them for changes.", + }, + { + id: "metadata", + label: "Metadata", + icon: Database, + title: "Metadata", + description: "Control where artwork and descriptions come from, and in which language.", + }, + { + id: "advanced", + label: "Advanced", + icon: SlidersHorizontal, + title: "Advanced", + description: "Optional background processing for this library.", + }, +]; + +function sectionForErrors(errors: LibraryFormErrors): SectionId | null { + if (errors.name) return "general"; + if (errors.paths) return "folders"; + return null; +} + +export interface LibraryEditorDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + library: Library | null; + chapterThumbnailsSupported: boolean; +} + +export function LibraryEditorDialog({ + open, + onOpenChange, + library, + chapterThumbnailsSupported, +}: LibraryEditorDialogProps) { + return ( + + + onOpenChange(false)} + /> + + + ); +} + +function LibraryEditorBody({ + library, + chapterThumbnailsSupported, + onClose, +}: { + library: Library | null; + chapterThumbnailsSupported: boolean; + onClose: () => void; +}) { + const [section, setSection] = useState("general"); + const form = useLibraryForm({ library, onClose }); + + const typeMeta = libraryTypeMeta(form.type); + const folderCount = form.paths.filter((p) => p.trim()).length; + const errorSections = new Set(); + if (form.errors.name) errorSections.add("general"); + if (form.errors.paths) errorSections.add("folders"); + + function handleSubmit(e: FormEvent) { + e.preventDefault(); + const result = form.submit(); + if (!result.ok) { + const target = sectionForErrors(result.errors); + if (target) setSection(target); + } + } + + return ( +
+ +
+
+ +
+
+ {library ? "Edit Library" : "Add Library"} + + {library + ? `Configure how “${library.name}” is scanned and matched.` + : "Set up a new library from folders on your server."} + +
+
+
+ + setSection(value as SectionId)} + orientation="vertical" + className="min-h-0 flex-1 gap-0" + > +
+ + {SECTIONS.map(({ id, label, icon: Icon }) => ( + + + {label} + {errorSections.has(id) ? ( + + ) : id === "folders" && folderCount > 0 ? ( + + {folderCount} + + ) : null} + + ))} + +
+
+ {SECTIONS.map(({ id, title, description }) => ( + +
+

{title}

+

{description}

+
+ {id === "general" && ( + : null} + /> + )} + {id === "folders" && } + {id === "metadata" && } + {id === "advanced" && ( + + )} +
+ ))} +
+
+ +
+ {errorSections.size > 0 ? ( +

+ {[form.errors.name, form.errors.paths].filter(Boolean).join(" ")} +

+ ) : null} + + + + +
+
+ ); +} diff --git a/web/src/components/admin/libraries/LibraryForm.tsx b/web/src/components/admin/libraries/LibraryForm.tsx index cf946bf4..ad39b515 100644 --- a/web/src/components/admin/libraries/LibraryForm.tsx +++ b/web/src/components/admin/libraries/LibraryForm.tsx @@ -1,52 +1,10 @@ -import { useMemo, useState } from "react"; import type { FormEvent, ReactNode } from "react"; -import { - ArrowDown, - ArrowUp, - ChevronDown, - ChevronRight, - FolderOpen, - Plus, - Trash2, -} from "lucide-react"; -import type { CreateLibraryRequest, Library } from "@/api/types"; +import type { Library } from "@/api/types"; import { Button } from "@/components/ui/button"; -import FolderBrowser from "@/components/FolderBrowser"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import PathAutocompleteInput from "@/components/PathAutocompleteInput"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Switch } from "@/components/ui/switch"; -import { - useCreateLibrary, - useLibraryProviders, - useSetLibraryProviders, - useUpdateLibrary, -} from "@/hooks/queries/admin/libraries"; -import { useAdminPlugins } from "@/hooks/queries/admin/plugins"; -import { cn } from "@/lib/utils"; -import { LANGUAGES } from "@/player/utils/languageNames"; -type LevelChainItem = { - plugin_installation_id: number; - capability_id: string; - provider_slug: string; - enabled: boolean; -}; - -type MetadataProvider = { - plugin_installation_id: number; - capability_id: string; - slug: string; - defaultPriority: Record; -}; +import { AdvancedFields, FolderFields, GeneralFields, MetadataFields } from "./LibraryFormSections"; +import { useLibraryForm } from "./useLibraryForm"; export interface LibraryFormProps { library: Library | null; @@ -56,189 +14,24 @@ export interface LibraryFormProps { resetAfterCreate?: boolean; submitLabel?: string; savingLabel?: string; - extraContent?: ReactNode; } -function contentLevelsForType(libraryType: string): string[] { - switch (libraryType) { - case "series": - return ["series", "season", "episode"]; - case "movies": - return ["movie"]; - case "mixed": - return ["movie", "series", "season", "episode"]; - case "audiobooks": - return ["audiobook"]; - case "podcasts": - return ["podcast", "podcast_episode"]; - default: - return []; - } -} - -function buildDefaultLevelChains( - metadataProviders: MetadataProvider[], - libraryType: string, -): Record { - const defaultChain: Record = {}; - for (const level of contentLevelsForType(libraryType)) { - const sorted = [...metadataProviders].sort((a, b) => { - const pa = a.defaultPriority[level] ?? 0; - const pb = b.defaultPriority[level] ?? 0; - if ((pa === 0) !== (pb === 0)) return pa === 0 ? 1 : -1; - return pa - pb; - }); - defaultChain[level] = sorted.map((provider) => ({ - plugin_installation_id: provider.plugin_installation_id, - capability_id: provider.capability_id, - provider_slug: provider.slug, - enabled: (provider.defaultPriority[level] ?? 0) > 0, - })); - } - return defaultChain; -} - -function buildLevelChainsFromServer( - currentChain: { - levels?: Record< - string, - Array<{ - plugin_installation_id: number; - capability_id: string; - provider_slug: string; - enabled: boolean; - }> - >; - } | null, - metadataProviders: MetadataProvider[], - libraryType: string, -): Record { - const mapped: Record = {}; - if (currentChain?.levels) { - for (const [level, entries] of Object.entries(currentChain.levels)) { - mapped[level] = entries.map((entry) => ({ - plugin_installation_id: entry.plugin_installation_id, - capability_id: entry.capability_id, - provider_slug: entry.provider_slug, - enabled: entry.enabled, - })); - } - } - - const defaults = buildDefaultLevelChains(metadataProviders, libraryType); - for (const level of contentLevelsForType(libraryType)) { - if (!mapped[level] || mapped[level].length === 0) { - mapped[level] = defaults[level] ?? []; - } - } - return mapped; -} - -function contentLevelLabel(level: string): string { - return level - .split("_") - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(" "); -} - -function buildProviderChainBody(activeLevelChains: Record) { - return { - levels: Object.fromEntries( - Object.entries(activeLevelChains).map(([level, items]) => [ - level, - items.map((item, i) => ({ - plugin_installation_id: item.plugin_installation_id, - capability_id: item.capability_id, - priority: i, - enabled: item.enabled, - })), - ]), - ), - }; -} - -function ProviderLevelSection({ - level, - items, - onReorder, - onToggleEnabled, -}: { - level: string; - items: LevelChainItem[]; - onReorder: (items: LevelChainItem[]) => void; - onToggleEnabled: (index: number) => void; -}) { - const [collapsed, setCollapsed] = useState(false); - - const moveItem = (index: number, direction: -1 | 1) => { - const newItems = [...items]; - const target = index + direction; - if (target < 0 || target >= newItems.length) return; - [newItems[index], newItems[target]] = [newItems[target]!, newItems[index]!]; - onReorder(newItems); - }; - +function FormSection({ title, children }: { title: string; children: ReactNode }) { return ( -
- - {!collapsed && ( -
- {items.map((item, i) => ( -
- onToggleEnabled(i)} - className="h-3.5 w-3.5" - style={{ accentColor: "var(--primary)" }} - /> - {item.provider_slug} -
- - -
- {i + 1} -
- ))} -
- )} -
+
+

+ {title} +

+ {children} +
); } +/** + * Inline (non-dialog) library form used by the setup wizard. The admin + * Libraries page uses LibraryEditorDialog, which renders the same sections + * behind a left-hand navigation rail. + */ export function LibraryForm({ library, chapterThumbnailsSupported, @@ -247,324 +40,30 @@ export function LibraryForm({ resetAfterCreate = false, submitLabel = "Save", savingLabel = "Saving...", - extraContent, }: LibraryFormProps) { - const [name, setName] = useState(library?.name ?? ""); - const [paths, setPaths] = useState(library?.paths?.length ? library.paths : [""]); - const [type, setType] = useState(library?.type ?? "movies"); - const [enabled, setEnabled] = useState(library?.enabled ?? true); - const [metadataLanguage, setMetadataLanguage] = useState(library?.metadata_language ?? "en"); - const [chapterThumbnailsEnabled, setChapterThumbnailsEnabled] = useState( - library?.chapter_thumbnails_enabled ?? false, - ); - const [introDetectionEnabled, setIntroDetectionEnabled] = useState( - library?.intro_detection_enabled ?? false, - ); - const [levelChains, setLevelChains] = useState>({}); - const [chainDirty, setChainDirty] = useState(false); - const [browserOpen, setBrowserOpen] = useState(false); + const form = useLibraryForm({ library, onClose, onSaved, resetAfterCreate }); - const createMutation = useCreateLibrary(); - const updateMutation = useUpdateLibrary(); - const setChainMutation = useSetLibraryProviders(); - const { installations } = useAdminPlugins(); - const { data: currentChain } = useLibraryProviders(library?.id ?? null); - - const metadataProviders = useMemo(() => { - const result: MetadataProvider[] = []; - for (const inst of installations) { - if (!inst.enabled) continue; - for (const cap of inst.capabilities ?? []) { - if (cap.type === "metadata_provider.v1") { - const dp = - (cap.metadata?.default_priority as Record) ?? - ((cap.metadata?.metadata as Record)?.default_priority as Record< - string, - number - >) ?? - {}; - result.push({ - plugin_installation_id: inst.id, - capability_id: cap.id, - slug: cap.display_name || cap.id, - defaultPriority: dp, - }); - } - } - } - return result; - }, [installations]); - - const isPending = - createMutation.isPending || updateMutation.isPending || setChainMutation.isPending; - - const defaultLevelChains = useMemo( - () => buildDefaultLevelChains(metadataProviders, type), - [metadataProviders, type], - ); - const resolvedLevelChains = useMemo(() => { - if (!library) { - return defaultLevelChains; - } - if (currentChain === undefined) { - return levelChains; - } - return buildLevelChainsFromServer(currentChain, metadataProviders, type); - }, [currentChain, defaultLevelChains, levelChains, library, metadataProviders, type]); - const activeLevelChains = chainDirty ? levelChains : resolvedLevelChains; - - function updatePath(index: number, value: string) { - const next = [...paths]; - next[index] = value; - setPaths(next); - } - - function addPath() { - setPaths([...paths, ""]); - } - - function removePath(index: number) { - setPaths(paths.filter((_, i) => i !== index)); - } - - function handleBrowseSelect(selectedPaths: string[]) { - const merged = [...paths.filter((path) => path.trim())]; - for (const selectedPath of selectedPaths) { - if (!merged.includes(selectedPath)) { - merged.push(selectedPath); - } - } - setPaths(merged.length > 0 ? merged : [""]); - setBrowserOpen(false); - } - - function handleTypeChange(newType: string) { - setType(newType); - if (!library) { - setLevelChains(buildDefaultLevelChains(metadataProviders, newType)); - setChainDirty(true); - } - } - - function finishCreate(created: Library) { - onSaved?.(created); - if (resetAfterCreate) { - setName(""); - setPaths([""]); - } else { - onClose?.(); - } - } - - async function handleSubmit(e: FormEvent) { + function handleSubmit(e: FormEvent) { e.preventDefault(); - const filteredPaths = paths.filter((p) => p.trim()); - if (filteredPaths.length === 0) return; - - const body: CreateLibraryRequest = { - name, - paths: filteredPaths, - type, - enabled, - metadata_language: metadataLanguage, - chapter_thumbnails_enabled: chapterThumbnailsEnabled, - intro_detection_enabled: introDetectionEnabled, - }; - - if (library) { - updateMutation.mutate( - { id: library.id, body }, - { - onSuccess: () => { - if (chainDirty) { - setChainMutation.mutate( - { - id: library.id, - body: buildProviderChainBody(activeLevelChains), - }, - { onSuccess: () => onClose?.() }, - ); - } else { - onClose?.(); - } - }, - }, - ); - return; - } - - createMutation.mutate(body, { - onSuccess: (created) => { - if (chainDirty) { - setChainMutation.mutate( - { - id: created.id, - body: buildProviderChainBody(activeLevelChains), - }, - { onSuccess: () => finishCreate(created) }, - ); - } else { - finishCreate(created); - } - }, - }); + form.submit(); } return ( -
-
-
- - setName(e.target.value)} required /> -
-
- - -
-
-
- - {paths.map((p, i) => ( -
- updatePath(i, value)} - placeholder="/mnt/media/movies" - required - /> - {paths.length > 1 && ( - - )} -
- ))} -
- - -
- path.trim())} - /> -
-
-
- - -
-
- - -
-
-
-
-
- -

- Stores chapter preview images in the configured public asset S3 bucket. Chapter - markers and chapter menus still work without thumbnails. -

- {!chapterThumbnailsSupported ? ( -

- Public asset S3 storage is required before this can be enabled. -

- ) : null} -
- -
-
-
-
-
- -

- Runs background audio analysis for episodes in this library. Embedded intro chapters - are used when available. -

-
- -
-
- {extraContent} - - {contentLevelsForType(type).length > 0 && ( -
-

Metadata Providers

- {contentLevelsForType(type).map((level) => { - const items = activeLevelChains[level] ?? []; - return ( - { - setLevelChains({ ...activeLevelChains, [level]: newItems }); - setChainDirty(true); - }} - onToggleEnabled={(index) => { - setLevelChains((prev) => { - const source = prev[level] ?? activeLevelChains[level] ?? []; - const updated = [...source]; - updated[index] = { ...updated[index]!, enabled: !updated[index]!.enabled }; - return { ...prev, [level]: updated }; - }); - setChainDirty(true); - }} - /> - ); - })} -
- )} - -
); diff --git a/web/src/components/admin/libraries/LibraryFormSections.tsx b/web/src/components/admin/libraries/LibraryFormSections.tsx new file mode 100644 index 00000000..b9c2d895 --- /dev/null +++ b/web/src/components/admin/libraries/LibraryFormSections.tsx @@ -0,0 +1,359 @@ +import { useState } from "react"; +import type { ReactNode } from "react"; +import { + ArrowDown, + ArrowUp, + ChevronDown, + ChevronRight, + FolderOpen, + FolderSearch, + Plus, + Trash2, +} from "lucide-react"; + +import FolderBrowser from "@/components/FolderBrowser"; +import PathAutocompleteInput from "@/components/PathAutocompleteInput"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { cn } from "@/lib/utils"; +import { LANGUAGES } from "@/player/utils/languageNames"; + +import { LIBRARY_TYPES } from "./libraryTypes"; +import { contentLevelLabel } from "./useLibraryForm"; +import type { LevelChainItem, LibraryFormController } from "./useLibraryForm"; + +function SettingCard({ + htmlFor, + title, + description, + children, + footer, +}: { + htmlFor?: string; + title: string; + description: string; + children: ReactNode; + footer?: ReactNode; +}) { + return ( +
+
+
+ +

{description}

+ {footer} +
+ {children} +
+
+ ); +} + +export function GeneralFields({ + form, + posterSlot, +}: { + form: LibraryFormController; + posterSlot?: ReactNode; +}) { + return ( +
+
+ + form.setName(e.target.value)} + placeholder="e.g. Movies" + aria-invalid={form.errors.name ? true : undefined} + /> + {form.errors.name ?

{form.errors.name}

: null} +
+
+ +
+ {LIBRARY_TYPES.map(({ value, label, icon: Icon }) => { + const selected = form.type === value; + return ( + + ); + })} +
+ {form.library && form.type !== form.library.type ? ( +

+ Changing the type of an existing library may require a full rescan to rematch items. +

+ ) : null} +
+ + + + {posterSlot} +
+ ); +} + +export function FolderFields({ form }: { form: LibraryFormController }) { + const [browserOpen, setBrowserOpen] = useState(false); + + return ( +
+
+ {form.paths.map((path, i) => ( +
+ + form.updatePath(i, value)} + placeholder="/mnt/media/movies" + aria-invalid={form.errors.paths ? true : undefined} + /> + {form.paths.length > 1 && ( + + )} +
+ ))} +
+ {form.errors.paths ?

{form.errors.paths}

: null} +
+ + +
+ { + form.mergeBrowsedPaths(selected); + setBrowserOpen(false); + }} + existingPaths={form.paths.filter((path) => path.trim())} + /> +
+ ); +} + +function ProviderLevelSection({ + level, + items, + onReorder, + onToggleEnabled, +}: { + level: string; + items: LevelChainItem[]; + onReorder: (items: LevelChainItem[]) => void; + onToggleEnabled: (index: number) => void; +}) { + const [collapsed, setCollapsed] = useState(false); + + const moveItem = (index: number, direction: -1 | 1) => { + const newItems = [...items]; + const target = index + direction; + if (target < 0 || target >= newItems.length) return; + [newItems[index], newItems[target]] = [newItems[target]!, newItems[index]!]; + onReorder(newItems); + }; + + return ( +
+ + {!collapsed && ( +
+ {items.map((item, i) => ( +
+ onToggleEnabled(i)} + className="h-3.5 w-3.5" + style={{ accentColor: "var(--primary)" }} + /> + {item.provider_slug} +
+ + +
+ {i + 1} +
+ ))} +
+ )} +
+ ); +} + +export function MetadataFields({ form }: { form: LibraryFormController }) { + return ( +
+
+ + +

+ Preferred language for titles, summaries, and artwork fetched from providers. +

+
+ {form.contentLevels.length > 0 && ( +
+ +

+ Providers are asked in order from top to bottom. Uncheck a provider to skip it for that + level. +

+ {form.hasMetadataProviders ? ( + form.contentLevels.map((level) => ( + form.reorderLevel(level, newItems)} + onToggleEnabled={(index) => form.toggleLevelProvider(level, index)} + /> + )) + ) : ( +

+ No metadata provider plugins are installed. Install one under Admin → Plugins to fetch + artwork and descriptions. +

+ )} +
+ )} +
+ ); +} + +export function AdvancedFields({ + form, + chapterThumbnailsSupported, +}: { + form: LibraryFormController; + chapterThumbnailsSupported: boolean; +}) { + return ( +
+ + Public asset S3 storage is required before this can be enabled. +

+ ) : null + } + > + +
+ + + +
+ ); +} diff --git a/web/src/components/admin/libraries/LibraryPosterSection.tsx b/web/src/components/admin/libraries/LibraryPosterSection.tsx new file mode 100644 index 00000000..cd945dac --- /dev/null +++ b/web/src/components/admin/libraries/LibraryPosterSection.tsx @@ -0,0 +1,73 @@ +import type { ChangeEvent } from "react"; +import { ImageIcon, Trash2 } from "lucide-react"; + +import type { Library } from "@/api/types"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { useDeleteLibraryPoster, useUploadLibraryPoster } from "@/hooks/queries/admin/libraries"; + +export function LibraryPosterSection({ library }: { library: Library }) { + const uploadMutation = useUploadLibraryPoster(); + const deleteMutation = useDeleteLibraryPoster(); + const fileInputId = `poster-upload-${library.id}`; + + function handleFileChange(e: ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + uploadMutation.mutate({ id: library.id, file }); + e.target.value = ""; + } + + return ( +
+ +
+ {library.poster_url ? ( + {`${library.name} + ) : ( +
+ +
+ )} + + + {library.poster_url && ( + + )} +
+
+ ); +} diff --git a/web/src/components/admin/libraries/libraryTypes.ts b/web/src/components/admin/libraries/libraryTypes.ts new file mode 100644 index 00000000..30f1c406 --- /dev/null +++ b/web/src/components/admin/libraries/libraryTypes.ts @@ -0,0 +1,13 @@ +import { BookHeadphones, Film, Layers, Podcast, Tv } from "lucide-react"; + +export const LIBRARY_TYPES = [ + { value: "movies", label: "Movies", icon: Film }, + { value: "series", label: "Series", icon: Tv }, + { value: "mixed", label: "Mixed", icon: Layers }, + { value: "audiobooks", label: "Audiobooks", icon: BookHeadphones }, + { value: "podcasts", label: "Podcasts", icon: Podcast }, +] as const; + +export function libraryTypeMeta(type: string) { + return LIBRARY_TYPES.find((t) => t.value === type) ?? LIBRARY_TYPES[0]; +} diff --git a/web/src/components/admin/libraries/useLibraryForm.ts b/web/src/components/admin/libraries/useLibraryForm.ts new file mode 100644 index 00000000..4024ede1 --- /dev/null +++ b/web/src/components/admin/libraries/useLibraryForm.ts @@ -0,0 +1,358 @@ +import { useMemo, useState } from "react"; + +import type { CreateLibraryRequest, Library } from "@/api/types"; +import { + useCreateLibrary, + useLibraryProviders, + useSetLibraryProviders, + useUpdateLibrary, +} from "@/hooks/queries/admin/libraries"; +import { useAdminPlugins } from "@/hooks/queries/admin/plugins"; + +export type LevelChainItem = { + plugin_installation_id: number; + capability_id: string; + provider_slug: string; + enabled: boolean; +}; + +type MetadataProvider = { + plugin_installation_id: number; + capability_id: string; + slug: string; + defaultPriority: Record; +}; + +export interface LibraryFormErrors { + name?: string; + paths?: string; +} + +export interface UseLibraryFormOptions { + library: Library | null; + onClose?: () => void; + onSaved?: (library: Library) => void; + resetAfterCreate?: boolean; +} + +export function contentLevelsForType(libraryType: string): string[] { + switch (libraryType) { + case "series": + return ["series", "season", "episode"]; + case "movies": + return ["movie"]; + case "mixed": + return ["movie", "series", "season", "episode"]; + case "audiobooks": + return ["audiobook"]; + case "podcasts": + return ["podcast", "podcast_episode"]; + default: + return []; + } +} + +export function contentLevelLabel(level: string): string { + return level + .split("_") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +function buildDefaultLevelChains( + metadataProviders: MetadataProvider[], + libraryType: string, +): Record { + const defaultChain: Record = {}; + for (const level of contentLevelsForType(libraryType)) { + const sorted = [...metadataProviders].sort((a, b) => { + const pa = a.defaultPriority[level] ?? 0; + const pb = b.defaultPriority[level] ?? 0; + if ((pa === 0) !== (pb === 0)) return pa === 0 ? 1 : -1; + return pa - pb; + }); + defaultChain[level] = sorted.map((provider) => ({ + plugin_installation_id: provider.plugin_installation_id, + capability_id: provider.capability_id, + provider_slug: provider.slug, + enabled: (provider.defaultPriority[level] ?? 0) > 0, + })); + } + return defaultChain; +} + +function buildLevelChainsFromServer( + currentChain: { + levels?: Record< + string, + Array<{ + plugin_installation_id: number; + capability_id: string; + provider_slug: string; + enabled: boolean; + }> + >; + } | null, + metadataProviders: MetadataProvider[], + libraryType: string, +): Record { + const mapped: Record = {}; + if (currentChain?.levels) { + for (const [level, entries] of Object.entries(currentChain.levels)) { + mapped[level] = entries.map((entry) => ({ + plugin_installation_id: entry.plugin_installation_id, + capability_id: entry.capability_id, + provider_slug: entry.provider_slug, + enabled: entry.enabled, + })); + } + } + + const defaults = buildDefaultLevelChains(metadataProviders, libraryType); + for (const level of contentLevelsForType(libraryType)) { + if (!mapped[level] || mapped[level].length === 0) { + mapped[level] = defaults[level] ?? []; + } + } + return mapped; +} + +function buildProviderChainBody(activeLevelChains: Record) { + return { + levels: Object.fromEntries( + Object.entries(activeLevelChains).map(([level, items]) => [ + level, + items.map((item, i) => ({ + plugin_installation_id: item.plugin_installation_id, + capability_id: item.capability_id, + priority: i, + enabled: item.enabled, + })), + ]), + ), + }; +} + +export function useLibraryForm({ + library, + onClose, + onSaved, + resetAfterCreate = false, +}: UseLibraryFormOptions) { + const [name, setName] = useState(library?.name ?? ""); + const [paths, setPaths] = useState(library?.paths?.length ? library.paths : [""]); + const [type, setType] = useState(library?.type ?? "movies"); + const [enabled, setEnabled] = useState(library?.enabled ?? true); + const [metadataLanguage, setMetadataLanguage] = useState(library?.metadata_language ?? "en"); + const [chapterThumbnailsEnabled, setChapterThumbnailsEnabled] = useState( + library?.chapter_thumbnails_enabled ?? false, + ); + const [introDetectionEnabled, setIntroDetectionEnabled] = useState( + library?.intro_detection_enabled ?? false, + ); + const [levelChains, setLevelChains] = useState>({}); + const [chainDirty, setChainDirty] = useState(false); + const [submitAttempted, setSubmitAttempted] = useState(false); + + const createMutation = useCreateLibrary(); + const updateMutation = useUpdateLibrary(); + const setChainMutation = useSetLibraryProviders(); + const { installations } = useAdminPlugins(); + const { data: currentChain } = useLibraryProviders(library?.id ?? null); + + const metadataProviders = useMemo(() => { + const result: MetadataProvider[] = []; + for (const inst of installations) { + if (!inst.enabled) continue; + for (const cap of inst.capabilities ?? []) { + if (cap.type === "metadata_provider.v1") { + const dp = + (cap.metadata?.default_priority as Record) ?? + ((cap.metadata?.metadata as Record)?.default_priority as Record< + string, + number + >) ?? + {}; + result.push({ + plugin_installation_id: inst.id, + capability_id: cap.id, + slug: cap.display_name || cap.id, + defaultPriority: dp, + }); + } + } + } + return result; + }, [installations]); + + const isPending = + createMutation.isPending || updateMutation.isPending || setChainMutation.isPending; + + const defaultLevelChains = useMemo( + () => buildDefaultLevelChains(metadataProviders, type), + [metadataProviders, type], + ); + const resolvedLevelChains = useMemo(() => { + if (!library) { + return defaultLevelChains; + } + if (currentChain === undefined) { + return levelChains; + } + return buildLevelChainsFromServer(currentChain, metadataProviders, type); + }, [currentChain, defaultLevelChains, levelChains, library, metadataProviders, type]); + const activeLevelChains = chainDirty ? levelChains : resolvedLevelChains; + + const allErrors = useMemo(() => { + const next: LibraryFormErrors = {}; + if (!name.trim()) next.name = "Give this library a name."; + if (!paths.some((p) => p.trim())) next.paths = "Add at least one folder to scan."; + return next; + }, [name, paths]); + const errors: LibraryFormErrors = submitAttempted ? allErrors : {}; + + function updatePath(index: number, value: string) { + const next = [...paths]; + next[index] = value; + setPaths(next); + } + + function addPath() { + setPaths([...paths, ""]); + } + + function removePath(index: number) { + setPaths(paths.filter((_, i) => i !== index)); + } + + function mergeBrowsedPaths(selectedPaths: string[]) { + const merged = [...paths.filter((path) => path.trim())]; + for (const selectedPath of selectedPaths) { + if (!merged.includes(selectedPath)) { + merged.push(selectedPath); + } + } + setPaths(merged.length > 0 ? merged : [""]); + } + + function handleTypeChange(newType: string) { + setType(newType); + if (!library) { + setLevelChains(buildDefaultLevelChains(metadataProviders, newType)); + setChainDirty(true); + } + } + + function reorderLevel(level: string, items: LevelChainItem[]) { + setLevelChains({ ...activeLevelChains, [level]: items }); + setChainDirty(true); + } + + function toggleLevelProvider(level: string, index: number) { + setLevelChains((prev) => { + const source = prev[level] ?? activeLevelChains[level] ?? []; + const updated = [...source]; + updated[index] = { ...updated[index]!, enabled: !updated[index]!.enabled }; + return { ...prev, [level]: updated }; + }); + setChainDirty(true); + } + + function finishCreate(created: Library) { + onSaved?.(created); + if (resetAfterCreate) { + setName(""); + setPaths([""]); + setSubmitAttempted(false); + } else { + onClose?.(); + } + } + + function submit(): { ok: boolean; errors: LibraryFormErrors } { + setSubmitAttempted(true); + if (allErrors.name || allErrors.paths) { + return { ok: false, errors: allErrors }; + } + + const body: CreateLibraryRequest = { + name: name.trim(), + paths: paths.filter((p) => p.trim()), + type, + enabled, + metadata_language: metadataLanguage, + chapter_thumbnails_enabled: chapterThumbnailsEnabled, + intro_detection_enabled: introDetectionEnabled, + }; + + if (library) { + updateMutation.mutate( + { id: library.id, body }, + { + onSuccess: () => { + if (chainDirty) { + setChainMutation.mutate( + { + id: library.id, + body: buildProviderChainBody(activeLevelChains), + }, + { onSuccess: () => onClose?.() }, + ); + } else { + onClose?.(); + } + }, + }, + ); + return { ok: true, errors: {} }; + } + + createMutation.mutate(body, { + onSuccess: (created) => { + if (chainDirty) { + setChainMutation.mutate( + { + id: created.id, + body: buildProviderChainBody(activeLevelChains), + }, + { onSuccess: () => finishCreate(created) }, + ); + } else { + finishCreate(created); + } + }, + }); + return { ok: true, errors: {} }; + } + + return { + library, + name, + setName, + paths, + updatePath, + addPath, + removePath, + mergeBrowsedPaths, + type, + handleTypeChange, + enabled, + setEnabled, + metadataLanguage, + setMetadataLanguage, + chapterThumbnailsEnabled, + setChapterThumbnailsEnabled, + introDetectionEnabled, + setIntroDetectionEnabled, + contentLevels: contentLevelsForType(type), + activeLevelChains, + reorderLevel, + toggleLevelProvider, + hasMetadataProviders: metadataProviders.length > 0, + errors, + isPending, + submit, + }; +} + +export type LibraryFormController = ReturnType; diff --git a/web/src/pages/AdminLibraries.tsx b/web/src/pages/AdminLibraries.tsx index 14170626..5e7ef3b3 100644 --- a/web/src/pages/AdminLibraries.tsx +++ b/web/src/pages/AdminLibraries.tsx @@ -29,15 +29,13 @@ import { useRefreshLibraryMetadata, useCancelAdminJob, useConfirmEmptyRootCleanup, - useUploadLibraryPoster, - useDeleteLibraryPoster, useUnmatchedLibraryItems, UNMATCHED_PAGE_SIZE, } from "@/hooks/queries/admin/libraries"; import { useActiveScans } from "@/hooks/queries/admin/scans"; import { buildLibraryReorderEntries } from "./adminLibraryOrder"; import MatchItemDialog from "@/components/MatchItemDialog"; -import { LibraryForm } from "@/components/admin/libraries/LibraryForm"; +import { LibraryEditorDialog } from "@/components/admin/libraries/LibraryEditorDialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -56,7 +54,6 @@ import { DialogHeader, DialogDescription, DialogTitle, - DialogTrigger, } from "@/components/ui/dialog"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { @@ -78,7 +75,6 @@ import { GripVertical, Wrench, HardDrive, - ImageIcon, ChevronLeft, ChevronRight, ChevronsLeft, @@ -348,48 +344,28 @@ export default function AdminLibraries() { Catalog Maintenance - { + setEditingLib(null); + setDialogOpen(true); + }} + > + Add Library + + { setDialogOpen(open); if (!open) setEditingLib(null); }} - > - - - - - - {editingLib ? "Edit Library" : "Add Library"} - - Configure scan roots, metadata sources, and optional chapter thumbnails for this - library. - - - { - setDialogOpen(false); - setEditingLib(null); - }} - extraContent={ - editingLib ? ( -
- -
- ) : null - } - /> -
-
+ library={editingLib} + chapterThumbnailsSupported={ + editingLib?.chapter_thumbnails_supported ?? + libraries[0]?.chapter_thumbnails_supported ?? + true + } + /> @@ -2082,69 +2058,3 @@ function StaleIDsSection({ staleIDs }: { staleIDs: StaleMediaID[] }) { ); } - -function LibraryPosterSection({ library }: { library: Library }) { - const uploadMutation = useUploadLibraryPoster(); - const deleteMutation = useDeleteLibraryPoster(); - const fileInputId = `poster-upload-${library.id}`; - - function handleFileChange(e: React.ChangeEvent) { - const file = e.target.files?.[0]; - if (!file) return; - uploadMutation.mutate({ id: library.id, file }); - e.target.value = ""; - } - - return ( -
- -
- {library.poster_url ? ( - {`${library.name} - ) : ( -
- -
- )} - - - {library.poster_url && ( - - )} -
-
- ); -}