502 lines
18 KiB
TypeScript
502 lines
18 KiB
TypeScript
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";
|
|||
|
|
|
|||
|
|
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 ?? "");
|
|||
|
|
|
|||
|
|
// 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,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
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}
|
|||
|
|
|
|||
|
|
<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={100}
|
|||
|
|
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>
|
|||
|
|
);
|
|||
|
|
}
|