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>
227 lines
7.5 KiB
TypeScript
227 lines
7.5 KiB
TypeScript
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>
|
|
);
|
|
}
|