Files
silo-server/web/src/pages/SmartCollectionWizard.tsx
T

751 lines
24 KiB
TypeScript

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<CollectionBuilderValue>(initialDraft);
// Poster state lives at the wizard level so it survives Step1 ↔ Step2 jumps.
const [posterFile, setPosterFile] = useState<File | null>(null);
const [posterSourceUrl, setPosterSourceUrl] = useState("");
// Admin-only state still lives here so it survives step jumps too.
const [backdropFile, setBackdropFile] = useState<File | null>(null);
const [backdropSourceUrl, setBackdropSourceUrl] = useState("");
const [step, setStep] = useState<WizardStep>(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 (
<div className="page-shell-wide relative space-y-6 py-4 sm:py-6">
<PageBack to={backTarget} preferHistory={false} />
<WizardHeader title={headerTitle} step={step} isEdit={isEdit} />
{step === 1 ? (
<Step1FiltersAndPreview
mode={wizard.mode}
draft={draft}
onQueryDefinitionChange={handleQueryDefinitionChange}
adminLibraries={adminLibraries}
onContinue={() => setStep(2)}
/>
) : wizard.mode === "user" ? (
<Step2UserMetadata
wizard={wizard}
draft={draft}
onDraftChange={setDraft}
posterFile={posterFile}
onPosterFileChange={setPosterFile}
posterSourceUrl={posterSourceUrl}
onPosterSourceUrlChange={setPosterSourceUrl}
onBack={() => setStep(1)}
/>
) : (
<Step2AdminMetadata
wizard={wizard}
draft={draft}
onDraftChange={setDraft}
posterFile={posterFile}
onPosterFileChange={setPosterFile}
posterSourceUrl={posterSourceUrl}
onPosterSourceUrlChange={setPosterSourceUrl}
backdropFile={backdropFile}
onBackdropFileChange={setBackdropFile}
backdropSourceUrl={backdropSourceUrl}
onBackdropSourceUrlChange={setBackdropSourceUrl}
onBack={() => setStep(1)}
/>
)}
</div>
);
}
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 (
<div className="mt-10 flex flex-wrap items-end justify-between gap-4 sm:mt-12">
<div>
<h1 className="page-title text-[clamp(1.75rem,3vw,2.5rem)]">{title}</h1>
<p className="page-subtitle mt-1 text-sm">
{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."}
</p>
</div>
<StepIndicator step={step} />
</div>
);
}
function StepIndicator({ step }: { step: WizardStep }) {
return (
<ol className="text-muted-foreground flex items-center gap-2 text-xs font-medium">
<StepBadge index={1} label="Filters" active={step === 1} done={step > 1} />
<span aria-hidden="true" className="bg-border h-px w-6" />
<StepBadge index={2} label="Details" active={step === 2} done={false} />
</ol>
);
}
function StepBadge({
index,
label,
active,
done,
}: {
index: number;
label: string;
active: boolean;
done: boolean;
}) {
return (
<li className="flex items-center gap-2">
<span
className={
active
? "border-primary bg-primary text-primary-foreground flex h-6 w-6 items-center justify-center rounded-full border text-[11px]"
: done
? "border-primary text-primary flex h-6 w-6 items-center justify-center rounded-full border text-[11px]"
: "border-border text-muted-foreground flex h-6 w-6 items-center justify-center rounded-full border text-[11px]"
}
>
{done ? <Check className="h-3 w-3" /> : index}
</span>
<span className={active ? "text-foreground" : ""}>{label}</span>
</li>
);
}
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<ReturnType<typeof setTimeout>>(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 (
<div className="space-y-5 pb-24">
<CatalogFiltersPanel
state={catalogState}
onStateChange={(nextState) => onQueryDefinitionChange(nextState.query_definition)}
libraries={filterLibraries}
allowLibrarySelection
showMediaScopeSelector
allowPersonalizedFilters={mode === "user"}
allowPersonalizedSorts={mode === "user"}
resultCountLabel={itemCountLabel}
resultCountLoading={isLoading}
/>
<SmartCollectionLimitField query={queryDefinition} onQueryChange={onQueryDefinitionChange} />
{totalItems === 0 && !isLoading ? (
<div className="text-muted-foreground flex flex-col items-center justify-center gap-2 rounded-2xl border border-dashed py-16 text-center">
<p className="text-sm font-medium">No titles match these filters yet.</p>
<p className="text-xs">
Loosen the filters above to see what your collection would include.
</p>
</div>
) : (
<ItemGrid
totalItems={totalItems}
pages={pages}
pageSize={PAGE_LIMIT}
loading={isLoading}
onVisibleRangeChange={handleVisibleRangeChange}
sortField={queryDefinition.sort.field}
/>
)}
<div className="bg-background/95 supports-[backdrop-filter]:bg-background/70 fixed right-4 bottom-4 z-40 flex items-center gap-3 rounded-2xl border px-4 py-3 shadow-xl backdrop-blur sm:right-6 lg:right-10 xl:right-12">
<span className="text-muted-foreground hidden text-xs sm:inline">
{adminMissingLibrary
? "Pick at least one library to continue"
: totalItems > 0
? `${itemCountLabel} match these filters`
: "Choose filters to preview matching titles"}
</span>
<Button
type="button"
onClick={onContinue}
disabled={adminMissingLibrary || (!isLoading && totalItems === 0)}
>
Next: Details
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
);
}
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 (
<Step2Shell onBack={onBack} onSubmit={handleSave}>
<div className="grid gap-6 md:grid-cols-[minmax(0,1fr)_18rem]">
<div className="space-y-5">
<Field
id="collection-name"
label="Name"
required
value={draft.title}
disabled={readOnly}
onChange={(value) => onDraftChange({ ...draft, title: value })}
placeholder="e.g. Saturday Night Movies"
/>
<section className="space-y-3">
<h2 className="text-base font-semibold">Sharing</h2>
<CollectionAccessEditor
value={draft.access}
onChange={(access) => onDraftChange({ ...draft, access })}
profiles={profiles.map((p) => ({ id: p.id, name: p.name }))}
readOnly={readOnly}
creatorProfileId={collection?.creator_profile_id ?? null}
/>
</section>
<ToggleRow
title="Show in my library Collections tab"
description="Pin this collection to your library's Collections tab alongside the admin shelves. Only you see it — personal collections are private to your user."
checked={draft.include_in_server_collections}
disabled={readOnly}
onCheckedChange={(checked) =>
onDraftChange({ ...draft, include_in_server_collections: checked })
}
/>
</div>
<div className="space-y-3">
<h2 className="text-base font-semibold">Poster</h2>
<ImageUploadField
label="Poster"
currentUrl={collection?.poster_url}
file={posterFile}
onFileChange={onPosterFileChange}
sourceUrl={posterSourceUrl}
onSourceUrlChange={onPosterSourceUrlChange}
onDelete={
collection?.poster_url
? () => deletePosterMutation.mutate({ id: collection.id, type: "poster" })
: undefined
}
/>
</div>
</div>
<SaveBar
onBack={onBack}
canSave={canSave}
isPending={isPending}
saveLabel={collection ? "Save Collection" : "Create Collection"}
/>
</Step2Shell>
);
}
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 (
<Step2Shell onBack={onBack} onSubmit={handleSave}>
<div className="grid gap-6 md:grid-cols-[minmax(0,1fr)_22rem]">
<div className="space-y-5">
<Field
id="admin-collection-title"
label="Title"
required
value={draft.title}
onChange={(value) => onDraftChange({ ...draft, title: value })}
placeholder="e.g. Critically Acclaimed"
/>
<div className="space-y-2">
<Label htmlFor="admin-collection-description">Description</Label>
<textarea
id="admin-collection-description"
rows={3}
value={draft.description}
onChange={(event) => onDraftChange({ ...draft, description: event.target.value })}
className="border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 flex min-h-[88px] w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px]"
placeholder="Optional context shown alongside the collection."
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Visibility</Label>
<Select
value={draft.visibility}
onValueChange={(value) =>
onDraftChange({ ...draft, visibility: value as "visible" | "hidden" })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="visible">Visible</SelectItem>
<SelectItem value="hidden">Hidden</SelectItem>
</SelectContent>
</Select>
</div>
<ToggleRow
title="Featured"
description="Surface near the top of the library."
checked={draft.featured}
onCheckedChange={(checked) => onDraftChange({ ...draft, featured: checked })}
/>
</div>
{!hasLibrary ? (
<p className="text-destructive text-xs">
Pick at least one library in the Filters step before saving.
</p>
) : null}
</div>
<div className="space-y-4">
<ImageUploadField
label="Poster"
currentUrl={collection?.poster_url}
file={posterFile}
onFileChange={onPosterFileChange}
sourceUrl={posterSourceUrl}
onSourceUrlChange={onPosterSourceUrlChange}
onDelete={
collection
? () =>
deleteImage.mutate({
id: collection.id,
type: "poster",
libraryId: collection.library_id,
})
: undefined
}
/>
<ImageUploadField
label="Backdrop"
currentUrl={collection?.backdrop_url}
file={backdropFile}
onFileChange={onBackdropFileChange}
sourceUrl={backdropSourceUrl}
onSourceUrlChange={onBackdropSourceUrlChange}
onDelete={
collection
? () =>
deleteImage.mutate({
id: collection.id,
type: "backdrop",
libraryId: collection.library_id,
})
: undefined
}
/>
</div>
</div>
<SaveBar
onBack={onBack}
canSave={canSave}
isPending={isPending}
saveLabel={collection ? "Save Collection" : "Create Collection"}
/>
</Step2Shell>
);
}
function Step2Shell({
children,
onBack,
onSubmit,
}: {
children: React.ReactNode;
onBack: () => void;
onSubmit: () => void;
}) {
return (
<form
className="space-y-6 pb-24"
onSubmit={(event) => {
event.preventDefault();
onSubmit();
}}
>
<Button type="button" variant="ghost" size="sm" className="w-fit px-0" onClick={onBack}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Filters
</Button>
{children}
</form>
);
}
function SaveBar({
onBack,
canSave,
isPending,
saveLabel,
}: {
onBack: () => void;
canSave: boolean;
isPending: boolean;
saveLabel: string;
}) {
return (
<div className="bg-background/95 supports-[backdrop-filter]:bg-background/70 fixed right-4 bottom-4 z-40 flex items-center gap-3 rounded-2xl border px-4 py-3 shadow-xl backdrop-blur sm:right-6 lg:right-10 xl:right-12">
<Button type="button" variant="outline" size="sm" onClick={onBack} disabled={isPending}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Button type="submit" disabled={!canSave || isPending}>
{isPending ? "Saving…" : saveLabel}
</Button>
</div>
);
}
function Field({
id,
label,
value,
onChange,
required,
placeholder,
disabled,
}: {
id: string;
label: string;
value: string;
onChange: (value: string) => void;
required?: boolean;
placeholder?: string;
disabled?: boolean;
}) {
return (
<div className="space-y-2">
<Label htmlFor={id}>{label}</Label>
<Input
id={id}
value={value}
required={required}
placeholder={placeholder}
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
/>
</div>
);
}
function ToggleRow({
title,
description,
checked,
onCheckedChange,
disabled,
}: {
title: string;
description: string;
checked: boolean;
onCheckedChange: (checked: boolean) => void;
disabled?: boolean;
}) {
return (
<div className="border-border flex items-center justify-between gap-4 rounded-lg border px-4 py-3">
<div className="pr-2">
<p className="text-sm font-medium">{title}</p>
<p className="text-muted-foreground mt-0.5 text-xs">{description}</p>
</div>
<Switch checked={checked} onCheckedChange={onCheckedChange} disabled={disabled} />
</div>
);
}