Files
silo-server/web/src/components/CollectionTemplateGallery/CollectionTemplateConfigForm.tsx
T

519 lines
19 KiB
TypeScript
Raw Normal View History

2026-05-22 20:26:11 -04:00
import { useState } from "react";
import type { FormEvent } from "react";
import {
useImportMDBListCollection,
useImportTMDBCollection,
useImportTraktCollection,
} from "@/hooks/queries/admin/collections";
import { useProfiles } from "@/hooks/queries/profiles";
import type { Library } from "@/api/types";
import { libraryEligibilityForMediaKind, mediaKindLabel } from "@/lib/collectionTemplates";
import type { CollectionTemplate, LibraryEligibility } from "@/lib/collectionTemplates";
import {
CollectionLibraryPicker,
parseOptionalPositiveInteger,
} from "@/pages/adminCollectionsShared";
import { Badge } from "@/components/ui/badge";
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 { SyncScheduleField } from "@/components/collections/SyncScheduleField";
import { MDBListBrowser } from "./MDBListBrowser";
import { TemplatePosterField, type TemplatePosterMode } from "./TemplatePosterField";
2026-05-22 20:26:11 -04:00
interface Props {
template: CollectionTemplate;
libraries: Library[];
initialLibraryId: number | null;
onCancel: () => void;
onCreated: () => void;
}
function isEligibleTemplateLibrary(
library: Pick<Library, "type">,
eligibility: LibraryEligibility,
): boolean {
const kinds = eligibility.kinds;
if (!kinds || kinds.length === 0) return true;
if (!library.type) return true;
if (library.type === "mixed") return true;
return kinds.includes(library.type);
}
function initialTemplateLibraryIds(
libraries: Library[],
initialLibraryId: number | null,
eligibility: LibraryEligibility,
): number[] {
const eligibleLibraries = libraries.filter((library) =>
isEligibleTemplateLibrary(library, eligibility),
);
if (eligibleLibraries.length === 0) return [];
if (initialLibraryId) {
const initialLibrary = eligibleLibraries.find((library) => library.id === initialLibraryId);
if (initialLibrary) return [initialLibrary.id];
}
const [firstEligibleLibrary] = eligibleLibraries;
return firstEligibleLibrary ? [firstEligibleLibrary.id] : [];
}
export function CollectionTemplateConfigForm({
template,
libraries,
initialLibraryId,
onCancel,
onCreated,
}: Props) {
const tmdbMutation = useImportTMDBCollection();
const traktMutation = useImportTraktCollection();
const mdblistMutation = useImportMDBListCollection();
const { data: profiles = [] } = useProfiles();
const eligibility = libraryEligibilityForMediaKind(template.media_kind);
const [libraryIds, setLibraryIds] = useState<number[]>(() =>
initialTemplateLibraryIds(libraries, initialLibraryId, eligibility),
);
const [title, setTitle] = useState(template.title);
const [description, setDescription] = useState(template.description);
const [limit, setLimit] = useState(template.default_limit ? String(template.default_limit) : "");
const [syncSchedule, setSyncSchedule] = useState(template.default_sync_schedule ?? "");
const [featured, setFeatured] = useState(template.featured ?? true);
const defaultProfileId = template.requires_profile ? (profiles[0]?.id ?? "") : "";
const [profileId, setProfileId] = useState(defaultProfileId);
const [mdblistUrl, setMdblistUrl] = useState(template.mdblist?.url ?? "");
const [posterMode, setPosterMode] = useState<TemplatePosterMode>(() =>
template.poster_path ? "default" : "custom",
);
const [customPosterUrl, setCustomPosterUrl] = useState("");
2026-05-22 20:26:11 -04:00
// Discover- and Collection-source templates are bundle-only: the spec is
// backend-driven and can't be edited inline. Render a read-only summary so
// admins know to apply them via Template Bundles. The early returns MUST
// sit AFTER every useState above so React's rules-of-hooks lint stays
// green — the unused state slots are cheap and isolate this branch from
// the editable-form path.
if (template.source === "tmdb_collection") {
return <TMDBCollectionTemplateSummary template={template} onCancel={onCancel} />;
}
if (template.source === "tmdb_discover") {
return <TMDBDiscoverTemplateSummary template={template} onCancel={onCancel} />;
}
// When profiles load after initial render and the template needs one but
// no value has been chosen yet, settle on the first profile.
if (template.requires_profile && profileId === "" && profiles[0]?.id) {
setProfileId(profiles[0].id);
}
const parsedLimit = parseOptionalPositiveInteger(limit);
const limitInvalid = limit.trim().length > 0 && parsedLimit === undefined;
const isPending = tmdbMutation.isPending || traktMutation.isPending || mdblistMutation.isPending;
const missingLibrary = libraryIds.length === 0;
const missingProfile = template.requires_profile && profileId === "";
const missingMDBListURL = template.source === "mdblist" && mdblistUrl.trim().length === 0;
const submitDisabled =
isPending || missingLibrary || missingProfile || missingMDBListURL || limitInvalid;
function handleSubmit(event: FormEvent) {
event.preventDefault();
if (submitDisabled) return;
const sharedFields = {
library_ids: libraryIds,
title: title.trim() || template.title,
description: description.trim() || undefined,
featured,
sync_schedule: syncSchedule.trim() || undefined,
limit: parsedLimit,
...(posterMode === "custom"
? { poster_source_url: customPosterUrl.trim() || undefined }
: { poster_url: template.poster_path || undefined }),
2026-05-22 20:26:11 -04:00
};
if (template.source === "tmdb" && template.tmdb) {
tmdbMutation.mutate(
{
body: {
...sharedFields,
preset: template.tmdb.preset,
media_type: template.tmdb.media_type,
time_window: template.tmdb.time_window,
},
},
{ onSuccess: onCreated },
);
return;
}
if (template.source === "trakt" && template.trakt) {
traktMutation.mutate(
{
body: {
...sharedFields,
preset: template.trakt.preset,
media_type: template.trakt.media_type,
profile_id: template.requires_profile ? profileId : undefined,
},
},
{ onSuccess: onCreated },
);
return;
}
if (template.source === "mdblist" && template.mdblist) {
mdblistMutation.mutate(
{
body: {
...sharedFields,
url: mdblistUrl.trim(),
},
},
{ onSuccess: onCreated },
);
return;
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="border-border bg-muted/40 flex items-start gap-3 rounded-lg border p-3">
<div
className="bg-background text-primary flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-xl"
aria-hidden
>
{template.icon}
</div>
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold">{template.title}</span>
<Badge variant="outline" className="text-[10px] uppercase">
{template.source}
</Badge>
<Badge variant="secondary" className="text-[10px]">
{mediaKindLabel(template.media_kind)}
</Badge>
</div>
<p className="text-muted-foreground text-xs">{template.description}</p>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Libraries</Label>
<CollectionLibraryPicker
libraries={libraries}
value={libraryIds}
onChange={setLibraryIds}
eligibility={eligibility}
/>
</div>
<div className="space-y-2">
<Label htmlFor="template-title">Collection Title</Label>
<Input
id="template-title"
value={title}
onChange={(event) => setTitle(event.target.value)}
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="template-description">Description</Label>
<Input
id="template-description"
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder="Optional summary shown to viewers"
/>
</div>
{template.source === "mdblist" ? (
<div className="space-y-3">
<MDBListBrowser
onPick={(list, jsonURL) => {
setMdblistUrl(jsonURL);
if (!title.trim() || title.trim() === template.title) {
setTitle(list.name);
}
}}
/>
<div className="space-y-2">
<Label htmlFor="template-mdblist-url">MDBList URL</Label>
<Input
id="template-mdblist-url"
value={mdblistUrl}
onChange={(event) => setMdblistUrl(event.target.value)}
placeholder="https://mdblist.com/lists/user/slug"
required
/>
<p className="text-muted-foreground text-xs">
Pick a list above or paste any public MDBList list URL (with or without{" "}
<code>/json</code>). Items resolve via TMDB/IMDb/TVDB IDs.
</p>
</div>
</div>
) : null}
{template.requires_profile ? (
<div className="space-y-2">
<Label>Profile</Label>
<Select value={profileId} onValueChange={setProfileId}>
<SelectTrigger>
<SelectValue placeholder="Choose a profile" />
</SelectTrigger>
<SelectContent>
{profiles.map((profile) => (
<SelectItem key={profile.id} value={profile.id}>
{profile.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
This template uses the chosen profile's connected Trakt account from Watch Providers
settings.
</p>
</div>
) : null}
<TemplatePosterField
template={template}
mode={posterMode}
onModeChange={setPosterMode}
customUrl={customPosterUrl}
onCustomUrlChange={setCustomPosterUrl}
inputId="template-poster-url"
/>
2026-05-22 20:26:11 -04:00
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="template-limit">Max Items</Label>
<Input
id="template-limit"
type="number"
min={1}
max={200}
2026-05-22 20:26:11 -04:00
step={1}
inputMode="numeric"
value={limit}
onChange={(event) => setLimit(event.target.value)}
placeholder={template.default_limit ? String(template.default_limit) : "No limit"}
/>
</div>
<div className="space-y-2">
<Label>Featured</Label>
<div className="border-border flex h-9 items-center justify-between rounded-md border px-3">
<span className="text-muted-foreground text-xs">Surface in hero shelves</span>
<Switch checked={featured} onCheckedChange={setFeatured} />
</div>
</div>
</div>
<SyncScheduleField value={syncSchedule} onChange={setSyncSchedule} />
<div className="border-border flex justify-end gap-2 border-t pt-4">
<Button type="button" variant="ghost" onClick={onCancel} disabled={isPending}>
Cancel
</Button>
<Button type="submit" disabled={submitDisabled}>
{isPending ? "Importing..." : "Create Collection"}
</Button>
</div>
</form>
);
}
interface TMDBCollectionTemplateSummaryProps {
template: CollectionTemplate;
onCancel: () => void;
}
// TMDBCollectionTemplateSummary is the read-only view shown when an admin
// opens a `tmdb_collection` template directly from the gallery. The spec
// (TMDB collection ID, sort order, sync schedule) ships from the backend
// catalog and isn't user-editable at apply-time — the official flow is to
// apply via Template Bundles, where bulk creation, dedupe by management key,
// and featured-section wiring are all handled in one shot.
function TMDBCollectionTemplateSummary({ template, onCancel }: TMDBCollectionTemplateSummaryProps) {
const collectionId = template.tmdb_collection?.collection_id ?? 0;
const isPlaceholder = collectionId === 0;
return (
<div className="space-y-4">
<div className="border-border bg-muted/40 flex items-start gap-3 rounded-lg border p-3">
<div
className="bg-background text-primary flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-xl"
aria-hidden
>
{template.icon}
</div>
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold">{template.title}</span>
<Badge variant="outline" className="text-[10px] uppercase">
{template.source}
</Badge>
<Badge variant="secondary" className="text-[10px]">
{mediaKindLabel(template.media_kind)}
</Badge>
</div>
<p className="text-muted-foreground text-xs">{template.description}</p>
</div>
</div>
<dl className="grid gap-2 text-sm sm:grid-cols-2">
<div className="space-y-0.5">
<dt className="text-muted-foreground text-xs uppercase">TMDB Collection ID</dt>
<dd className="font-mono text-sm">
{isPlaceholder ? "(unset — admin fills in at apply)" : collectionId}
</dd>
</div>
{typeof template.default_sort_order === "number" ? (
<div className="space-y-0.5">
<dt className="text-muted-foreground text-xs uppercase">Default Sort Order</dt>
<dd className="font-mono text-sm">{template.default_sort_order}</dd>
</div>
) : null}
</dl>
<p className="text-muted-foreground border-border bg-muted/30 rounded-md border p-3 text-xs">
TMDB franchise templates are bundle-driven. Apply them through Template Bundles so the
management key, library scoping, and sync schedule are wired up consistently across
libraries.
{isPlaceholder ? (
<>
{" "}
This template is a placeholder; after applying, edit the collection's source_config and
replace <code>collection_id: 0</code> with the real TMDB collection ID before the first
sync.
</>
) : null}
</p>
<div className="border-border flex justify-end gap-2 border-t pt-4">
<Button type="button" variant="ghost" onClick={onCancel}>
Close
</Button>
<Button type="button" disabled>
Use Template Bundles
</Button>
</div>
</div>
);
}
interface TMDBDiscoverTemplateSummaryProps {
template: CollectionTemplate;
onCancel: () => void;
}
// summarizeDiscoverSpec produces a one-line human description of the discover
// filter set so admins can see at a glance what the template will fetch.
function summarizeDiscoverSpec(spec: NonNullable<CollectionTemplate["tmdb_discover"]>): string {
const parts: string[] = [];
parts.push(spec.media_type === "tv" ? "TV" : "movies");
parts.push(`sorted by ${spec.sort_by}`);
if (spec.with_genres?.length) {
parts.push(`genres ${spec.with_genres.join(", ")}`);
}
if (spec.vote_count_gte) {
parts.push(`votes ≥ ${spec.vote_count_gte}`);
}
if (spec.vote_average_gte) {
parts.push(`rating ≥ ${spec.vote_average_gte}`);
}
if (spec.release_date_gte || spec.release_date_lte) {
const lo = spec.release_date_gte ?? "…";
const hi = spec.release_date_lte ?? "…";
parts.push(`released ${lo}–${hi}`);
}
if (spec.certifications?.length) {
parts.push(`certs ${spec.certifications.join("/")}`);
}
if (spec.certification_lte) {
parts.push(`cert ≤ ${spec.certification_lte}`);
}
if (spec.original_language) {
parts.push(`lang ${spec.original_language}`);
}
return parts.join(" · ");
}
// TMDBDiscoverTemplateSummary mirrors TMDBCollectionTemplateSummary: TMDB
// discover templates ship as backend-driven blueprints (genre matrices etc.)
// and apply through Template Bundles, not the per-template create form.
function TMDBDiscoverTemplateSummary({ template, onCancel }: TMDBDiscoverTemplateSummaryProps) {
const spec = template.tmdb_discover;
return (
<div className="space-y-4">
<div className="border-border bg-muted/40 flex items-start gap-3 rounded-lg border p-3">
<div
className="bg-background text-primary flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-xl"
aria-hidden
>
{template.icon}
</div>
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold">{template.title}</span>
<Badge variant="outline" className="text-[10px] uppercase">
{template.source}
</Badge>
<Badge variant="secondary" className="text-[10px]">
{mediaKindLabel(template.media_kind)}
</Badge>
</div>
<p className="text-muted-foreground text-xs">{template.description}</p>
</div>
</div>
{spec ? (
<dl className="grid gap-2 text-sm sm:grid-cols-2">
<div className="space-y-0.5">
<dt className="text-muted-foreground text-xs uppercase">Filter Summary</dt>
<dd className="text-sm">{summarizeDiscoverSpec(spec)}</dd>
</div>
{typeof template.default_sort_order === "number" ? (
<div className="space-y-0.5">
<dt className="text-muted-foreground text-xs uppercase">Default Sort Order</dt>
<dd className="font-mono text-sm">{template.default_sort_order}</dd>
</div>
) : null}
</dl>
) : null}
<p className="text-muted-foreground border-border bg-muted/30 rounded-md border p-3 text-xs">
Discover templates are applied via Template Bundles. The filter set ships from the backend
catalog; apply through a bundle so the management key, library scoping, and sync schedule
are wired up consistently across libraries.
</p>
<div className="border-border flex justify-end gap-2 border-t pt-4">
<Button type="button" variant="ghost" onClick={onCancel}>
Close
</Button>
<Button type="button" disabled>
Use Template Bundles
</Button>
</div>
</div>
);
}