import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ArrowLeft, ArrowRight, Check } from "lucide-react"; import type { Collection, CreateCollectionRequest, CreateLibraryCollectionRequest, Library, LibraryCollection, QueryDefinition, UpdateCollectionRequest, } from "@/api/types"; import { normalizeQueryDefinition } from "@/api/types"; import CatalogFiltersPanel from "@/components/catalog/CatalogFiltersPanel"; import { ImageUploadField } from "@/components/ImageUploadField"; import ItemGrid from "@/components/ItemGrid"; import PageBack from "@/components/PageBack"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { createCollectionBuilderValue, SmartCollectionLimitField, type CollectionBuilderValue, } from "@/components/collections/CollectionBuilder"; import { withSmartCollectionLimit } from "@/components/collections/smartCollectionLimits"; import CollectionAccessEditor from "@/components/collections/CollectionAccessEditor"; import { useCatalogWindow } from "@/hooks/queries/catalog"; import { useCreateCollection, useDeleteUserCollectionImage, useUpdateCollection, } from "@/hooks/queries/collections"; import { useCreateAdminCollection, useDeleteCollectionImage, useUpdateAdminCollection, } from "@/hooks/queries/admin/collections"; import { useUserLibraries } from "@/hooks/queries/libraries"; import { useProfiles } from "@/hooks/queries/profiles"; import { useCurrentProfile } from "@/hooks/useCurrentProfile"; import { useDocumentTitle } from "@/hooks/useDocumentTitle"; import { isCollectionReadOnly, toCreateCollectionBody, toUpdateCollectionBody, toUserCollectionBuilderValue, } from "./userCollectionsShared"; import { toAdminCollectionBuilderValue, toAdminCollectionRequest } from "./adminCollectionsShared"; import type { CatalogSearchState } from "./catalogSearchParams"; type WizardStep = 1 | 2; type UserModeProps = { mode: "user"; collection: Collection | null; onClose: () => void; }; type AdminModeProps = { mode: "admin"; collection: LibraryCollection | null; libraries: Library[]; initialLibraryId: number | null; onClose: () => void; }; export type SmartCollectionWizardProps = UserModeProps | AdminModeProps; const PAGE_LIMIT = 60; export default function SmartCollectionWizard(wizard: SmartCollectionWizardProps) { const isEdit = wizard.collection !== null; const adminLibraryId = wizard.mode === "admin" ? wizard.initialLibraryId : null; const initialDraft = useMemo( () => { if (wizard.mode === "user") { return smartUserDraft(wizard.collection); } return toAdminCollectionBuilderValue(wizard.collection, wizard.initialLibraryId); }, // Seed once per mounted collection. For admin's create flow (no // collection.id), initialLibraryId is the only seed signal — re-seed when // it changes so library_ids reflects the URL. // eslint-disable-next-line react-hooks/exhaustive-deps [wizard.collection?.id ?? null, adminLibraryId], ); const [draft, setDraft] = useState(initialDraft); // Poster state lives at the wizard level so it survives Step1 ↔ Step2 jumps. const [posterFile, setPosterFile] = useState(null); const [posterSourceUrl, setPosterSourceUrl] = useState(""); // Admin-only state still lives here so it survives step jumps too. const [backdropFile, setBackdropFile] = useState(null); const [backdropSourceUrl, setBackdropSourceUrl] = useState(""); const [step, setStep] = useState(1); useEffect(() => { setDraft(initialDraft); setPosterFile(null); setPosterSourceUrl(""); setBackdropFile(null); setBackdropSourceUrl(""); setStep(1); }, [initialDraft]); useDocumentTitle(isEdit ? `Edit ${draft.title || "Collection"}` : "New Collection"); const handleQueryDefinitionChange = useCallback((next: QueryDefinition) => { setDraft((current) => ({ ...current, query_definition: withSmartCollectionLimit(next) })); }, []); const headerTitle = isEdit ? draft.title || "Edit Collection" : "New Collection"; const backTarget = wizard.mode === "user" ? "/collections" : adminBackHref(wizard); const adminLibraries = wizard.mode === "admin" ? wizard.libraries : []; return (
{step === 1 ? ( setStep(2)} /> ) : wizard.mode === "user" ? ( setStep(1)} /> ) : ( setStep(1)} /> )}
); } function smartUserDraft(collection: Collection | null): CollectionBuilderValue { const value = toUserCollectionBuilderValue(collection); return value.collection_type === "smart" ? value : createCollectionBuilderValue({ ...value, collection_type: "smart" }); } function adminBackHref(props: AdminModeProps): string { return props.initialLibraryId ? `/admin/collections?libraryId=${props.initialLibraryId}` : "/admin/collections"; } function WizardHeader({ title, step, isEdit, }: { title: string; step: WizardStep; isEdit: boolean; }) { return (

{title}

{step === 1 ? "Tune the filters until the cards below show the collection you want." : isEdit ? "Update naming, artwork, and sharing for this collection." : "Give your new collection a name, artwork, and sharing rules."}

); } function StepIndicator({ step }: { step: WizardStep }) { return (
    1} />
); } function StepBadge({ index, label, active, done, }: { index: number; label: string; active: boolean; done: boolean; }) { return (
  • {done ? : index} {label}
  • ); } function Step1FiltersAndPreview({ mode, draft, onQueryDefinitionChange, adminLibraries, onContinue, }: { mode: "user" | "admin"; draft: CollectionBuilderValue; onQueryDefinitionChange: (next: QueryDefinition) => void; adminLibraries: Library[]; onContinue: () => void; }) { const queryDefinition = useMemo( () => normalizeQueryDefinition(draft.query_definition), [draft.query_definition], ); const filtersKey = useMemo(() => JSON.stringify(queryDefinition), [queryDefinition]); const [visibleRangeState, setVisibleRangeState] = useState<{ key: string; range: [number, number]; }>({ key: filtersKey, range: [0, PAGE_LIMIT - 1] }); const visibleRange = visibleRangeState.key === filtersKey ? visibleRangeState.range : ([0, PAGE_LIMIT - 1] as [number, number]); const debounceRef = useRef>(undefined); const handleVisibleRangeChange = useCallback( (start: number, end: number) => { clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { setVisibleRangeState({ key: filtersKey, range: [start, end] }); }, 50); }, [filtersKey], ); useEffect(() => () => clearTimeout(debounceRef.current), []); const catalogState: CatalogSearchState = useMemo( () => ({ source: "query", query_definition: queryDefinition, }), [queryDefinition], ); const catalogQuery = useCatalogWindow(catalogState, { limit: PAGE_LIMIT, includeTotal: true, visibleRange, }); const totalItems = catalogQuery.data?.totalItems ?? 0; const pages = catalogQuery.data?.pages ?? new Map(); const isLoading = catalogQuery.isLoading; const itemCountLabel = `${totalItems.toLocaleString()} item${totalItems === 1 ? "" : "s"}`; const adminMissingLibrary = mode === "admin" && draft.query_definition.library_ids.length === 0; const { data: userLibraries = [] } = useUserLibraries(); const filterLibraries = useMemo( () => mode === "admin" ? adminLibraries.map((l) => ({ id: l.id, name: l.name })) : userLibraries.map((l) => ({ id: l.id, name: l.name })), [mode, adminLibraries, userLibraries], ); return (
    onQueryDefinitionChange(nextState.query_definition)} libraries={filterLibraries} allowLibrarySelection showMediaScopeSelector allowPersonalizedFilters={mode === "user"} allowPersonalizedSorts={mode === "user"} resultCountLabel={itemCountLabel} resultCountLoading={isLoading} /> {totalItems === 0 && !isLoading ? (

    No titles match these filters yet.

    Loosen the filters above to see what your collection would include.

    ) : ( )}
    {adminMissingLibrary ? "Pick at least one library to continue" : totalItems > 0 ? `${itemCountLabel} match these filters` : "Choose filters to preview matching titles"}
    ); } interface Step2BaseProps { draft: CollectionBuilderValue; onDraftChange: (next: CollectionBuilderValue) => void; posterFile: File | null; onPosterFileChange: (file: File | null) => void; posterSourceUrl: string; onPosterSourceUrlChange: (url: string) => void; onBack: () => void; } function Step2UserMetadata({ wizard, draft, onDraftChange, posterFile, onPosterFileChange, posterSourceUrl, onPosterSourceUrlChange, onBack, }: Step2BaseProps & { wizard: UserModeProps }) { const { profile } = useCurrentProfile(); const { data: profiles = [] } = useProfiles(); const createMutation = useCreateCollection(); const updateMutation = useUpdateCollection(); const deletePosterMutation = useDeleteUserCollectionImage(); const isPending = createMutation.isPending || updateMutation.isPending; const readOnly = isCollectionReadOnly(wizard.collection, profile?.id); const collection = wizard.collection; const canSave = !readOnly && draft.title.trim().length > 0; function handleSave() { const trimmedSource = posterSourceUrl.trim(); if (collection) { const body: UpdateCollectionRequest = { ...toUpdateCollectionBody(draft), poster_source_url: trimmedSource || undefined, }; updateMutation.mutate( { id: collection.id, body, poster: posterFile }, { onSuccess: wizard.onClose }, ); } else { const body: CreateCollectionRequest = { ...toCreateCollectionBody(draft), poster_source_url: trimmedSource || undefined, }; createMutation.mutate({ body, poster: posterFile }, { onSuccess: wizard.onClose }); } } return (
    onDraftChange({ ...draft, title: value })} placeholder="e.g. Saturday Night Movies" />

    Sharing

    onDraftChange({ ...draft, access })} profiles={profiles.map((p) => ({ id: p.id, name: p.name }))} readOnly={readOnly} creatorProfileId={collection?.creator_profile_id ?? null} />
    onDraftChange({ ...draft, include_in_server_collections: checked }) } />

    Poster

    deletePosterMutation.mutate({ id: collection.id, type: "poster" }) : undefined } />
    ); } interface Step2AdminMetadataProps extends Step2BaseProps { wizard: AdminModeProps; backdropFile: File | null; onBackdropFileChange: (file: File | null) => void; backdropSourceUrl: string; onBackdropSourceUrlChange: (url: string) => void; } function Step2AdminMetadata({ wizard, draft, onDraftChange, posterFile, onPosterFileChange, posterSourceUrl, onPosterSourceUrlChange, backdropFile, onBackdropFileChange, backdropSourceUrl, onBackdropSourceUrlChange, onBack, }: Step2AdminMetadataProps) { const createMutation = useCreateAdminCollection(); const updateMutation = useUpdateAdminCollection(); const deleteImage = useDeleteCollectionImage(); const isPending = createMutation.isPending || updateMutation.isPending; const collection = wizard.collection; const hasLibrary = draft.query_definition.library_ids.length > 0; const canSave = draft.title.trim().length > 0 && hasLibrary; function handleSave() { const baseBody = toAdminCollectionRequest(draft); const body: CreateLibraryCollectionRequest = { ...baseBody, poster_source_url: posterSourceUrl.trim() || undefined, backdrop_source_url: backdropSourceUrl.trim() || undefined, }; if (collection) { updateMutation.mutate( { id: collection.id, body, poster: posterFile, backdrop: backdropFile }, { onSuccess: wizard.onClose }, ); } else { createMutation.mutate( { body, poster: posterFile, backdrop: backdropFile }, { onSuccess: wizard.onClose }, ); } } return (
    onDraftChange({ ...draft, title: value })} placeholder="e.g. Critically Acclaimed" />