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"; interface Props { template: CollectionTemplate; libraries: Library[]; initialLibraryId: number | null; onCancel: () => void; onCreated: () => void; } function isEligibleTemplateLibrary( library: Pick, 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(() => 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(() => template.poster_path ? "default" : "custom", ); const [customPosterUrl, setCustomPosterUrl] = useState(""); // 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 ; } if (template.source === "tmdb_discover") { return ; } // 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 }), }; 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 (
{template.icon}
{template.title} {template.source} {mediaKindLabel(template.media_kind)}

{template.description}

setTitle(event.target.value)} required />
setDescription(event.target.value)} placeholder="Optional summary shown to viewers" />
{template.source === "mdblist" ? (
{ setMdblistUrl(jsonURL); if (!title.trim() || title.trim() === template.title) { setTitle(list.name); } }} />
setMdblistUrl(event.target.value)} placeholder="https://mdblist.com/lists/user/slug" required />

Pick a list above or paste any public MDBList list URL (with or without{" "} /json). Items resolve via TMDB/IMDb/TVDB IDs.

) : null} {template.requires_profile ? (

This template uses the chosen profile's connected Trakt account from Watch Providers settings.

) : null}
setLimit(event.target.value)} placeholder={template.default_limit ? String(template.default_limit) : "No limit"} />
Surface in hero shelves
); } 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 (
{template.icon}
{template.title} {template.source} {mediaKindLabel(template.media_kind)}

{template.description}

TMDB Collection ID
{isPlaceholder ? "(unset — admin fills in at apply)" : collectionId}
{typeof template.default_sort_order === "number" ? (
Default Sort Order
{template.default_sort_order}
) : null}

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 collection_id: 0 with the real TMDB collection ID before the first sync. ) : null}

); } 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): 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 (
{template.icon}
{template.title} {template.source} {mediaKindLabel(template.media_kind)}

{template.description}

{spec ? (
Filter Summary
{summarizeDiscoverSpec(spec)}
{typeof template.default_sort_order === "number" ? (
Default Sort Order
{template.default_sort_order}
) : null}
) : null}

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.

); }