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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex h-[min(40rem,calc(100dvh-4rem))] flex-col gap-0 overflow-hidden p-0 sm:max-w-3xl">
|
||||
<LibraryEditorBody
|
||||
key={library?.id ?? "new"}
|
||||
library={library}
|
||||
chapterThumbnailsSupported={chapterThumbnailsSupported}
|
||||
onClose={() => onOpenChange(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function LibraryEditorBody({
|
||||
library,
|
||||
chapterThumbnailsSupported,
|
||||
onClose,
|
||||
}: {
|
||||
library: Library | null;
|
||||
chapterThumbnailsSupported: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [section, setSection] = useState<SectionId>("general");
|
||||
const form = useLibraryForm({ library, onClose });
|
||||
|
||||
const typeMeta = libraryTypeMeta(form.type);
|
||||
const folderCount = form.paths.filter((p) => p.trim()).length;
|
||||
const errorSections = new Set<SectionId>();
|
||||
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 (
|
||||
<form onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col">
|
||||
<DialogHeader className="border-border shrink-0 border-b px-5 py-4 pr-12 sm:px-6 sm:pr-12">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="border-border bg-surface text-primary flex size-10 shrink-0 items-center justify-center rounded-xl border">
|
||||
<typeMeta.icon className="size-5" />
|
||||
</div>
|
||||
<div className="min-w-0 space-y-0.5 text-left">
|
||||
<DialogTitle>{library ? "Edit Library" : "Add Library"}</DialogTitle>
|
||||
<DialogDescription className="truncate text-xs">
|
||||
{library
|
||||
? `Configure how “${library.name}” is scanned and matched.`
|
||||
: "Set up a new library from folders on your server."}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs
|
||||
value={section}
|
||||
onValueChange={(value) => setSection(value as SectionId)}
|
||||
orientation="vertical"
|
||||
className="min-h-0 flex-1 gap-0"
|
||||
>
|
||||
<div className="border-border shrink-0 overflow-y-auto border-r">
|
||||
<TabsList className="w-13 flex-col items-stretch justify-start gap-1 rounded-none bg-transparent p-2 sm:w-44 sm:p-3">
|
||||
{SECTIONS.map(({ id, label, icon: Icon }) => (
|
||||
<TabsTrigger
|
||||
key={id}
|
||||
value={id}
|
||||
className="h-auto shrink-0 justify-start gap-2.5 rounded-lg px-2.5 py-2 sm:px-3"
|
||||
>
|
||||
<Icon className="size-4 shrink-0" />
|
||||
<span className="hidden sm:inline">{label}</span>
|
||||
{errorSections.has(id) ? (
|
||||
<span
|
||||
className={cn(
|
||||
"bg-destructive size-1.5 rounded-full",
|
||||
"absolute top-1 right-1 sm:static sm:ml-auto",
|
||||
)}
|
||||
/>
|
||||
) : id === "folders" && folderCount > 0 ? (
|
||||
<span className="text-muted-foreground ml-auto hidden font-mono text-[10px] tabular-nums sm:inline">
|
||||
{folderCount}
|
||||
</span>
|
||||
) : null}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
<div className="overlay-scroll min-h-0 flex-1 overflow-y-auto">
|
||||
{SECTIONS.map(({ id, title, description }) => (
|
||||
<TabsContent
|
||||
key={id}
|
||||
value={id}
|
||||
className="animate-in fade-in-0 slide-in-from-right-1 px-5 py-5 duration-200 sm:px-6"
|
||||
>
|
||||
<div className="mb-5 space-y-1">
|
||||
<h3 className="text-sm font-semibold">{title}</h3>
|
||||
<p className="text-muted-foreground text-xs">{description}</p>
|
||||
</div>
|
||||
{id === "general" && (
|
||||
<GeneralFields
|
||||
form={form}
|
||||
posterSlot={library ? <LibraryPosterSection library={library} /> : null}
|
||||
/>
|
||||
)}
|
||||
{id === "folders" && <FolderFields form={form} />}
|
||||
{id === "metadata" && <MetadataFields form={form} />}
|
||||
{id === "advanced" && (
|
||||
<AdvancedFields
|
||||
form={form}
|
||||
chapterThumbnailsSupported={chapterThumbnailsSupported}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
))}
|
||||
</div>
|
||||
</Tabs>
|
||||
|
||||
<div className="border-border flex shrink-0 items-center justify-end gap-2 border-t px-5 py-4 sm:px-6">
|
||||
{errorSections.size > 0 ? (
|
||||
<p className="text-destructive mr-auto text-xs">
|
||||
{[form.errors.name, form.errors.paths].filter(Boolean).join(" ")}
|
||||
</p>
|
||||
) : null}
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="ghost">
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit" disabled={form.isPending}>
|
||||
{form.isPending
|
||||
? library
|
||||
? "Saving…"
|
||||
: "Creating…"
|
||||
: library
|
||||
? "Save Changes"
|
||||
: "Create Library"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -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<string, number>;
|
||||
};
|
||||
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<string, LevelChainItem[]> {
|
||||
const defaultChain: Record<string, LevelChainItem[]> = {};
|
||||
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<string, LevelChainItem[]> {
|
||||
const mapped: Record<string, LevelChainItem[]> = {};
|
||||
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<string, LevelChainItem[]>) {
|
||||
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 (
|
||||
<div className="mb-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="text-primary hover:text-primary/80 mb-1.5 flex items-center gap-1.5 text-xs font-semibold tracking-wider uppercase"
|
||||
>
|
||||
{collapsed ? <ChevronRight className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
||||
{contentLevelLabel(level)}
|
||||
</button>
|
||||
{!collapsed && (
|
||||
<div className="flex flex-col gap-1">
|
||||
{items.map((item, i) => (
|
||||
<div
|
||||
key={`${item.plugin_installation_id}:${item.capability_id}`}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-sm",
|
||||
item.enabled
|
||||
? "border-border bg-muted text-foreground"
|
||||
: "border-border/50 bg-muted/30 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.enabled}
|
||||
onChange={() => onToggleEnabled(i)}
|
||||
className="h-3.5 w-3.5"
|
||||
style={{ accentColor: "var(--primary)" }}
|
||||
/>
|
||||
<span className="flex-1 font-mono text-xs">{item.provider_slug}</span>
|
||||
<div className="flex gap-0.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
disabled={i === 0}
|
||||
onClick={() => moveItem(i, -1)}
|
||||
>
|
||||
<ArrowUp className="h-2.5 w-2.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
disabled={i === items.length - 1}
|
||||
onClick={() => moveItem(i, 1)}
|
||||
>
|
||||
<ArrowDown className="h-2.5 w-2.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<span className="text-muted-foreground/70 font-mono text-[10px]">{i + 1}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted-foreground text-xs font-semibold tracking-[0.1em] uppercase">
|
||||
{title}
|
||||
</h3>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string[]>(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<Record<string, LevelChainItem[]>>({});
|
||||
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<string, number>) ??
|
||||
((cap.metadata?.metadata as Record<string, unknown>)?.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 (
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<div className="grid grid-cols-[1fr_auto] items-end gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Name</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} required />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pb-0.5">
|
||||
<Switch id="enabled-switch" checked={enabled} onCheckedChange={setEnabled} />
|
||||
<Label htmlFor="enabled-switch" className="text-muted-foreground text-xs">
|
||||
Enabled
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Paths</Label>
|
||||
{paths.map((p, i) => (
|
||||
<div key={i} className="flex gap-1">
|
||||
<PathAutocompleteInput
|
||||
value={p}
|
||||
onValueChange={(value) => updatePath(i, value)}
|
||||
placeholder="/mnt/media/movies"
|
||||
required
|
||||
/>
|
||||
{paths.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 shrink-0"
|
||||
onClick={() => removePath(i)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="flex gap-1">
|
||||
<Button type="button" variant="outline" size="sm" onClick={addPath}>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" /> Add Path
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setBrowserOpen(true)}>
|
||||
<FolderOpen className="mr-1 h-3.5 w-3.5" /> Browse
|
||||
</Button>
|
||||
</div>
|
||||
<FolderBrowser
|
||||
open={browserOpen}
|
||||
onOpenChange={setBrowserOpen}
|
||||
onSelect={handleBrowseSelect}
|
||||
existingPaths={paths.filter((path) => path.trim())}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 items-end gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Type</Label>
|
||||
<Select value={type} onValueChange={handleTypeChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<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>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Metadata Language</Label>
|
||||
<Select value={metadataLanguage} onValueChange={setMetadataLanguage}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LANGUAGES.map((lang) => (
|
||||
<SelectItem key={lang.code} value={lang.code}>
|
||||
{lang.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/10 bg-white/5 p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="chapter-thumbnails-switch">Generate chapter thumbnails</Label>
|
||||
<p className="text-xs text-white/60">
|
||||
Stores chapter preview images in the configured public asset S3 bucket. Chapter
|
||||
markers and chapter menus still work without thumbnails.
|
||||
</p>
|
||||
{!chapterThumbnailsSupported ? (
|
||||
<p className="text-xs text-amber-300">
|
||||
Public asset S3 storage is required before this can be enabled.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Switch
|
||||
id="chapter-thumbnails-switch"
|
||||
checked={chapterThumbnailsEnabled}
|
||||
disabled={!chapterThumbnailsSupported}
|
||||
onCheckedChange={setChapterThumbnailsEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/10 bg-white/5 p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="intro-detection-switch">Detect intro markers</Label>
|
||||
<p className="text-xs text-white/60">
|
||||
Runs background audio analysis for episodes in this library. Embedded intro chapters
|
||||
are used when available.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="intro-detection-switch"
|
||||
checked={introDetectionEnabled}
|
||||
onCheckedChange={setIntroDetectionEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{extraContent}
|
||||
|
||||
{contentLevelsForType(type).length > 0 && (
|
||||
<div className="mt-4 border-t border-white/10 pt-4">
|
||||
<h3 className="mb-3 text-sm font-semibold text-white">Metadata Providers</h3>
|
||||
{contentLevelsForType(type).map((level) => {
|
||||
const items = activeLevelChains[level] ?? [];
|
||||
return (
|
||||
<ProviderLevelSection
|
||||
key={level}
|
||||
level={level}
|
||||
items={items}
|
||||
onReorder={(newItems) => {
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isPending}>
|
||||
{isPending ? savingLabel : submitLabel}
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<FormSection title="General">
|
||||
<GeneralFields form={form} />
|
||||
</FormSection>
|
||||
<FormSection title="Folders">
|
||||
<FolderFields form={form} />
|
||||
</FormSection>
|
||||
<FormSection title="Metadata">
|
||||
<MetadataFields form={form} />
|
||||
</FormSection>
|
||||
<FormSection title="Advanced">
|
||||
<AdvancedFields form={form} chapterThumbnailsSupported={chapterThumbnailsSupported} />
|
||||
</FormSection>
|
||||
<Button type="submit" className="w-full" disabled={form.isPending}>
|
||||
{form.isPending ? savingLabel : submitLabel}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<div className="border-border bg-surface rounded-xl border p-3.5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={htmlFor}>{title}</Label>
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">{description}</p>
|
||||
{footer}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GeneralFields({
|
||||
form,
|
||||
posterSlot,
|
||||
}: {
|
||||
form: LibraryFormController;
|
||||
posterSlot?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="library-name">Name</Label>
|
||||
<Input
|
||||
id="library-name"
|
||||
value={form.name}
|
||||
onChange={(e) => form.setName(e.target.value)}
|
||||
placeholder="e.g. Movies"
|
||||
aria-invalid={form.errors.name ? true : undefined}
|
||||
/>
|
||||
{form.errors.name ? <p className="text-destructive text-xs">{form.errors.name}</p> : null}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Type</Label>
|
||||
<div
|
||||
className="grid grid-cols-3 gap-2 sm:grid-cols-5"
|
||||
role="radiogroup"
|
||||
aria-label="Library type"
|
||||
>
|
||||
{LIBRARY_TYPES.map(({ value, label, icon: Icon }) => {
|
||||
const selected = form.type === value;
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
onClick={() => form.handleTypeChange(value)}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1.5 rounded-xl border px-2 py-3 transition-colors duration-150",
|
||||
selected
|
||||
? "border-primary/50 bg-primary/10 text-foreground"
|
||||
: "border-border bg-surface text-muted-foreground hover:bg-surface-hover hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn("size-5", selected ? "text-primary" : "text-muted-foreground")}
|
||||
/>
|
||||
<span className="text-[11px] font-medium">{label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{form.library && form.type !== form.library.type ? (
|
||||
<p className="text-warning text-xs">
|
||||
Changing the type of an existing library may require a full rescan to rematch items.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<SettingCard
|
||||
htmlFor="library-enabled-switch"
|
||||
title="Enabled"
|
||||
description="Disabled libraries are hidden from browsing and skipped by scans."
|
||||
>
|
||||
<Switch
|
||||
id="library-enabled-switch"
|
||||
checked={form.enabled}
|
||||
onCheckedChange={form.setEnabled}
|
||||
/>
|
||||
</SettingCard>
|
||||
{posterSlot}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FolderFields({ form }: { form: LibraryFormController }) {
|
||||
const [browserOpen, setBrowserOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
{form.paths.map((path, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5">
|
||||
<FolderOpen className="text-muted-foreground/60 size-4 shrink-0" />
|
||||
<PathAutocompleteInput
|
||||
value={path}
|
||||
onValueChange={(value) => form.updatePath(i, value)}
|
||||
placeholder="/mnt/media/movies"
|
||||
aria-invalid={form.errors.paths ? true : undefined}
|
||||
/>
|
||||
{form.paths.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-destructive size-9 shrink-0"
|
||||
onClick={() => form.removePath(i)}
|
||||
title="Remove folder"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{form.errors.paths ? <p className="text-destructive text-xs">{form.errors.paths}</p> : null}
|
||||
<div className="flex gap-1.5">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setBrowserOpen(true)}>
|
||||
<FolderSearch className="mr-1 size-3.5" /> Browse
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={form.addPath}>
|
||||
<Plus className="mr-1 size-3.5" /> Add Path
|
||||
</Button>
|
||||
</div>
|
||||
<FolderBrowser
|
||||
open={browserOpen}
|
||||
onOpenChange={setBrowserOpen}
|
||||
onSelect={(selected) => {
|
||||
form.mergeBrowsedPaths(selected);
|
||||
setBrowserOpen(false);
|
||||
}}
|
||||
existingPaths={form.paths.filter((path) => path.trim())}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="mb-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="text-primary hover:text-primary/80 mb-1.5 flex items-center gap-1.5 text-xs font-semibold tracking-wider uppercase"
|
||||
>
|
||||
{collapsed ? <ChevronRight className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
||||
{contentLevelLabel(level)}
|
||||
</button>
|
||||
{!collapsed && (
|
||||
<div className="flex flex-col gap-1">
|
||||
{items.map((item, i) => (
|
||||
<div
|
||||
key={`${item.plugin_installation_id}:${item.capability_id}`}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-sm",
|
||||
item.enabled
|
||||
? "border-border bg-muted text-foreground"
|
||||
: "border-border/50 bg-muted/30 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.enabled}
|
||||
onChange={() => onToggleEnabled(i)}
|
||||
className="h-3.5 w-3.5"
|
||||
style={{ accentColor: "var(--primary)" }}
|
||||
/>
|
||||
<span className="flex-1 font-mono text-xs">{item.provider_slug}</span>
|
||||
<div className="flex gap-0.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
disabled={i === 0}
|
||||
onClick={() => moveItem(i, -1)}
|
||||
>
|
||||
<ArrowUp className="h-2.5 w-2.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
disabled={i === items.length - 1}
|
||||
onClick={() => moveItem(i, 1)}
|
||||
>
|
||||
<ArrowDown className="h-2.5 w-2.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<span className="text-muted-foreground/70 font-mono text-[10px]">{i + 1}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MetadataFields({ form }: { form: LibraryFormController }) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Metadata Language</Label>
|
||||
<Select value={form.metadataLanguage} onValueChange={form.setMetadataLanguage}>
|
||||
<SelectTrigger className="w-full sm:w-64">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LANGUAGES.map((lang) => (
|
||||
<SelectItem key={lang.code} value={lang.code}>
|
||||
{lang.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Preferred language for titles, summaries, and artwork fetched from providers.
|
||||
</p>
|
||||
</div>
|
||||
{form.contentLevels.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<Label>Provider Priority</Label>
|
||||
<p className="text-muted-foreground mb-3 text-xs">
|
||||
Providers are asked in order from top to bottom. Uncheck a provider to skip it for that
|
||||
level.
|
||||
</p>
|
||||
{form.hasMetadataProviders ? (
|
||||
form.contentLevels.map((level) => (
|
||||
<ProviderLevelSection
|
||||
key={level}
|
||||
level={level}
|
||||
items={form.activeLevelChains[level] ?? []}
|
||||
onReorder={(newItems) => form.reorderLevel(level, newItems)}
|
||||
onToggleEnabled={(index) => form.toggleLevelProvider(level, index)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p className="border-border bg-surface text-muted-foreground rounded-xl border border-dashed p-4 text-center text-xs">
|
||||
No metadata provider plugins are installed. Install one under Admin → Plugins to fetch
|
||||
artwork and descriptions.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdvancedFields({
|
||||
form,
|
||||
chapterThumbnailsSupported,
|
||||
}: {
|
||||
form: LibraryFormController;
|
||||
chapterThumbnailsSupported: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<SettingCard
|
||||
htmlFor="chapter-thumbnails-switch"
|
||||
title="Generate chapter thumbnails"
|
||||
description="Stores chapter preview images in the configured public asset S3 bucket. Chapter markers and chapter menus still work without thumbnails."
|
||||
footer={
|
||||
!chapterThumbnailsSupported ? (
|
||||
<p className="text-warning text-xs">
|
||||
Public asset S3 storage is required before this can be enabled.
|
||||
</p>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<Switch
|
||||
id="chapter-thumbnails-switch"
|
||||
checked={form.chapterThumbnailsEnabled}
|
||||
disabled={!chapterThumbnailsSupported}
|
||||
onCheckedChange={form.setChapterThumbnailsEnabled}
|
||||
/>
|
||||
</SettingCard>
|
||||
<SettingCard
|
||||
htmlFor="intro-detection-switch"
|
||||
title="Detect intro markers"
|
||||
description="Runs background audio analysis for episodes in this library. Embedded intro chapters are used when available."
|
||||
>
|
||||
<Switch
|
||||
id="intro-detection-switch"
|
||||
checked={form.introDetectionEnabled}
|
||||
onCheckedChange={form.setIntroDetectionEnabled}
|
||||
/>
|
||||
</SettingCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
uploadMutation.mutate({ id: library.id, file });
|
||||
e.target.value = "";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label>Poster</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
{library.poster_url ? (
|
||||
<img
|
||||
src={library.poster_url}
|
||||
alt={`${library.name} poster`}
|
||||
className="border-border h-14 flex-shrink-0 rounded border object-cover"
|
||||
style={{ aspectRatio: "16/9" }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="border-border bg-muted/30 flex h-14 flex-shrink-0 items-center justify-center rounded border border-dashed"
|
||||
style={{ aspectRatio: "16/9" }}
|
||||
>
|
||||
<ImageIcon className="text-muted-foreground/40 h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
id={fileInputId}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
onClick={() => document.getElementById(fileInputId)?.click()}
|
||||
disabled={uploadMutation.isPending}
|
||||
>
|
||||
{uploadMutation.isPending ? "..." : library.poster_url ? "Replace" : "Upload"}
|
||||
</Button>
|
||||
{library.poster_url && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-destructive h-8 w-8"
|
||||
onClick={() => deleteMutation.mutate(library.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
title="Remove poster"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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<string, number>;
|
||||
};
|
||||
|
||||
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<string, LevelChainItem[]> {
|
||||
const defaultChain: Record<string, LevelChainItem[]> = {};
|
||||
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<string, LevelChainItem[]> {
|
||||
const mapped: Record<string, LevelChainItem[]> = {};
|
||||
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<string, LevelChainItem[]>) {
|
||||
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<string[]>(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<Record<string, LevelChainItem[]>>({});
|
||||
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<string, number>) ??
|
||||
((cap.metadata?.metadata as Record<string, unknown>)?.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<LibraryFormErrors>(() => {
|
||||
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<typeof useLibraryForm>;
|
||||
@@ -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
|
||||
</Link>
|
||||
</Button>
|
||||
<Dialog
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingLib(null);
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" /> Add Library
|
||||
</Button>
|
||||
<LibraryEditorDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen(open);
|
||||
if (!open) setEditingLib(null);
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">
|
||||
<Plus className="mr-1 h-4 w-4" /> Add Library
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingLib ? "Edit Library" : "Add Library"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure scan roots, metadata sources, and optional chapter thumbnails for this
|
||||
library.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<LibraryForm
|
||||
key={editingLib?.id ?? "new"}
|
||||
library={editingLib}
|
||||
chapterThumbnailsSupported={
|
||||
editingLib?.chapter_thumbnails_supported ??
|
||||
libraries[0]?.chapter_thumbnails_supported ??
|
||||
true
|
||||
}
|
||||
onClose={() => {
|
||||
setDialogOpen(false);
|
||||
setEditingLib(null);
|
||||
}}
|
||||
extraContent={
|
||||
editingLib ? (
|
||||
<div>
|
||||
<LibraryPosterSection library={editingLib} />
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
library={editingLib}
|
||||
chapterThumbnailsSupported={
|
||||
editingLib?.chapter_thumbnails_supported ??
|
||||
libraries[0]?.chapter_thumbnails_supported ??
|
||||
true
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2082,69 +2058,3 @@ function StaleIDsSection({ staleIDs }: { staleIDs: StaleMediaID[] }) {
|
||||
</CollapsibleDiagnosticsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function LibraryPosterSection({ library }: { library: Library }) {
|
||||
const uploadMutation = useUploadLibraryPoster();
|
||||
const deleteMutation = useDeleteLibraryPoster();
|
||||
const fileInputId = `poster-upload-${library.id}`;
|
||||
|
||||
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
uploadMutation.mutate({ id: library.id, file });
|
||||
e.target.value = "";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label>Poster</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
{library.poster_url ? (
|
||||
<img
|
||||
src={library.poster_url}
|
||||
alt={`${library.name} poster`}
|
||||
className="border-border h-14 flex-shrink-0 rounded border object-cover"
|
||||
style={{ aspectRatio: "16/9" }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="border-border bg-muted/30 flex h-14 flex-shrink-0 items-center justify-center rounded border border-dashed"
|
||||
style={{ aspectRatio: "16/9" }}
|
||||
>
|
||||
<ImageIcon className="text-muted-foreground/40 h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
id={fileInputId}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
onClick={() => document.getElementById(fileInputId)?.click()}
|
||||
disabled={uploadMutation.isPending}
|
||||
>
|
||||
{uploadMutation.isPending ? "..." : library.poster_url ? "Replace" : "Upload"}
|
||||
</Button>
|
||||
{library.poster_url && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-destructive h-8 w-8"
|
||||
onClick={() => deleteMutation.mutate(library.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
title="Remove poster"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user