Files
silo-server/web/src/components/admin/libraries/LibraryEditorDialog.tsx
T
QuickandClaude Fable 5 b9a9613fd0 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>
2026-06-09 14:51:38 -04:00

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>
);
}