Files
silo-server/web/src/components/RecipeGallery/RecipeParamFields.tsx
T
QuickandClaude Fable 5 8fa40e928b feat(sections): wire watchlist/favorites rails with type and library filters
The dedicated watchlist/favorites section types were never dispatched by
the section fetcher, so their home rails always resolved empty. Resolve
them from the profile's user store, preserving stored order, and honor
the section config's filter_type / filter_library_ids so admins can
build e.g. separate "Movies Watchlist" and "TV Shows Watchlist" rails.
Apply the same filters on the catalog "see all" path, and expose Media
Type + Libraries pickers in the section editor for these types.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 16:07:49 -04:00

361 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { CollectionSearchableSelect } from "@/components/CollectionSearchableSelect";
import LibraryMultiSelect from "@/components/LibraryMultiSelect";
import { useAllUserCollections } from "@/hooks/queries/useAllUserCollections";
import { useAvailableUserLibraries } from "@/hooks/queries/libraries";
import type { RecipeDefinition } from "@/lib/recipes";
export interface RecipeParamFieldsProps {
def: RecipeDefinition;
params: Record<string, unknown>;
onChange: (next: Record<string, unknown>) => void;
}
export default function RecipeParamFields({ def, params, onChange }: RecipeParamFieldsProps) {
if (def.type === "collection") {
return <CollectionParamField params={params} onChange={onChange} />;
}
if (def.type === "continue_watching") {
return <ContinueTypeParamField params={params} onChange={onChange} />;
}
if (def.type === "watchlist" || def.type === "favorites") {
return <PersonalListFilterFields params={params} onChange={onChange} />;
}
if (def.type === "seasonal_themed") {
return <SeasonalParamField params={params} onChange={onChange} />;
}
if (def.type === "editorial_spotlight") {
const subjectType = (params.subject_type as string) ?? "director";
const autoRotate = (params.auto_rotate as boolean) ?? true;
const cadence = (params.rotation_cadence as string) ?? "weekly";
const subject = (params.subject as string) ?? "";
return (
<div className="space-y-3">
<label className="block">
<span className="mb-1 block text-xs text-white/70">Subject type</span>
<select
value={subjectType}
onChange={(e) => onChange({ ...params, subject_type: e.target.value })}
className="w-full rounded border border-white/15 bg-white/5 px-3 py-2 text-sm"
>
<option value="director">Director</option>
<option value="studio">Studio</option>
<option value="actor">Actor</option>
<option value="era">Era</option>
</select>
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={autoRotate}
onChange={(e) => onChange({ ...params, auto_rotate: e.target.checked })}
/>
Auto-rotate
</label>
{autoRotate ? (
<label className="block">
<span className="mb-1 block text-xs text-white/70">Rotation cadence</span>
<select
value={cadence}
onChange={(e) => onChange({ ...params, rotation_cadence: e.target.value })}
className="w-full rounded border border-white/15 bg-white/5 px-3 py-2 text-sm"
>
<option value="daily">Daily</option>
<option value="weekly">Weekly (default)</option>
<option value="monthly">Monthly</option>
</select>
</label>
) : (
<label className="block">
<span className="mb-1 block text-xs text-white/70">Subject</span>
<input
value={subject}
onChange={(e) => onChange({ ...params, subject: e.target.value })}
className="w-full rounded border border-white/15 bg-white/5 px-3 py-2 text-sm"
placeholder="e.g. Christopher Nolan"
/>
</label>
)}
</div>
);
}
if (def.type === "because_you_watched") {
const anchor = (params.anchor_item_id as string) ?? "";
return (
<div>
<label className="mb-1 block text-xs text-white/70">Anchor item</label>
<input
className="w-full rounded border border-white/15 bg-white/5 px-3 py-2 text-sm"
placeholder="Auto-pick latest watched (leave blank)"
value={anchor}
onChange={(e) => onChange({ ...params, anchor_item_id: e.target.value })}
/>
<div className="mt-1 text-[11px] text-white/50">
Leave blank to auto-pick the most recent watch.
</div>
</div>
);
}
if (def.type === "taste_match") {
const genre = (params.genre as string) ?? "";
return (
<div>
<label className="mb-1 block text-xs text-white/70">Genre (optional)</label>
<input
className="w-full rounded border border-white/15 bg-white/5 px-3 py-2 text-sm"
placeholder="e.g. Sci-Fi"
value={genre}
onChange={(e) => onChange({ ...params, genre: e.target.value })}
/>
</div>
);
}
return null;
}
interface ParamFieldProps {
params: Record<string, unknown>;
onChange: (next: Record<string, unknown>) => void;
}
// PersonalListFilterFields edits the optional filter_type / filter_library_ids
// filters for watchlist and favorites sections, e.g. a "Movies watchlist" rail.
function PersonalListFilterFields({ params, onChange }: ParamFieldProps) {
const { data: libraries } = useAvailableUserLibraries();
const filterType = typeof params.filter_type === "string" ? params.filter_type : "";
const libraryIds = Array.isArray(params.filter_library_ids)
? params.filter_library_ids.filter((id): id is number => typeof id === "number")
: [];
return (
<div className="grid gap-4 md:grid-cols-2">
<label className="block">
<span className="mb-1 block text-xs text-white/70">Media type</span>
<select
value={filterType || "all"}
onChange={(e) =>
onChange({
...params,
filter_type: e.target.value === "all" ? undefined : e.target.value,
})
}
className="w-full rounded border border-white/15 bg-white/5 px-3 py-2 text-sm"
>
<option value="all">All Media</option>
<option value="movie">Movies</option>
<option value="series">TV Shows</option>
<option value="audiobook">Audiobooks</option>
</select>
</label>
<label className="block">
<span className="mb-1 block text-xs text-white/70">Libraries</span>
<LibraryMultiSelect
libraries={libraries ?? []}
value={libraryIds}
onChange={(next) =>
onChange({ ...params, filter_library_ids: next.length > 0 ? next : undefined })
}
/>
</label>
</div>
);
}
function ContinueTypeParamField({ params, onChange }: ParamFieldProps) {
const continueType = params.continue_type === "listening" ? "listening" : "watching";
return (
<label className="block">
<span className="mb-1 block text-xs text-white/70">Continue type</span>
<select
value={continueType}
onChange={(event) => onChange({ ...params, continue_type: event.target.value })}
className="w-full rounded border border-white/15 bg-white/5 px-3 py-2 text-sm"
>
<option value="watching">Watching</option>
<option value="listening">Listening</option>
</select>
</label>
);
}
// Order matches SeasonalThemeOrder in the backend. Higher entries take
// priority when multiple enabled themes match the current date.
const SEASONAL_THEMES: Array<{ key: string; label: string; window: string; icon: string }> = [
{ key: "valentines", label: "Valentine's Day", window: "Feb 7–14", icon: "💝" },
{ key: "st_patricks", label: "St. Patrick's Day", window: "Mar 15–17", icon: "🍀" },
{ key: "thanksgiving", label: "Thanksgiving", window: "Nov 22–30", icon: "🦃" },
{ key: "christmas", label: "Christmas", window: "Dec 1–31", icon: "🎄" },
{ key: "halloween", label: "Halloween", window: "All October", icon: "🎃" },
{
key: "saturday_morning",
label: "Saturday Morning Cartoons",
window: "Saturday before 1pm",
icon: "📺",
},
{
key: "summer_blockbuster",
label: "Summer Blockbusters",
window: "June – August",
icon: "🌴",
},
];
function SeasonalParamField({ params, onChange }: ParamFieldProps) {
// Resolve the current enabled set, falling back to the legacy single-theme
// shape when the section was saved before EnabledThemes existed.
const rawEnabled = params.enabled_themes;
const enabled = new Set<string>(
Array.isArray(rawEnabled)
? rawEnabled.filter((v): v is string => typeof v === "string")
: typeof params.theme === "string" && params.theme
? [params.theme]
: [],
);
// theme_titles is an optional map of theme key → custom display name.
const rawTitles = params.theme_titles;
const themeTitles: Record<string, string> = {};
if (rawTitles && typeof rawTitles === "object" && !Array.isArray(rawTitles)) {
for (const [k, v] of Object.entries(rawTitles)) {
if (typeof v === "string") themeTitles[k] = v;
}
}
function commit(nextEnabled: Set<string>, nextTitles: Record<string, string>) {
// Drop empty/whitespace-only titles and titles for disabled themes so the
// saved config stays tidy.
const cleanedTitles: Record<string, string> = {};
for (const [k, v] of Object.entries(nextTitles)) {
if (!nextEnabled.has(k)) continue;
const trimmed = v.trim();
if (trimmed) cleanedTitles[k] = trimmed;
}
onChange({
...params,
enabled_themes: Array.from(nextEnabled),
theme_titles: Object.keys(cleanedTitles).length > 0 ? cleanedTitles : undefined,
// Clear legacy fields so they don't shadow multi-theme resolution.
theme: "",
mode: "",
});
}
function toggle(key: string, on: boolean) {
const next = new Set(enabled);
if (on) next.add(key);
else next.delete(key);
commit(next, themeTitles);
}
function setTitle(key: string, value: string) {
commit(enabled, { ...themeTitles, [key]: value });
}
return (
<div className="space-y-2">
<span className="block text-xs text-white/70">Holidays to celebrate</span>
<div className="space-y-2 rounded border border-white/10 bg-white/5 px-3 py-2">
{SEASONAL_THEMES.map((t) => {
const isOn = enabled.has(t.key);
return (
<div key={t.key} className="space-y-1">
<label className="flex items-center justify-between gap-3 text-sm">
<span className="flex items-center gap-2">
<span aria-hidden>{t.icon}</span>
<span>{t.label}</span>
<span className="text-[10px] text-white/40">{t.window}</span>
</span>
<input
type="checkbox"
checked={isOn}
onChange={(e) => toggle(t.key, e.target.checked)}
/>
</label>
{isOn && (
<input
type="text"
value={themeTitles[t.key] ?? ""}
onChange={(e) => setTitle(t.key, e.target.value)}
placeholder={`Section title in season — defaults to "${t.label}"`}
className="ml-7 w-[calc(100%-1.75rem)] rounded border border-white/10 bg-white/5 px-2 py-1 text-xs"
/>
)}
</div>
);
})}
</div>
<p className="text-[11px] text-white/50">
The section auto-cycles: it shows whichever enabled holiday is currently in season, and
hides itself when none match. Per-holiday titles override the section name only while that
holiday is active.
</p>
</div>
);
}
function CollectionParamField({ params, onChange }: ParamFieldProps) {
const { collections, isLoading } = useAllUserCollections();
const libraryID = (params.library_collection_id as string) ?? "";
const userID = (params.user_collection_id as string) ?? "";
const value = userID || libraryID;
const sourceProvider = typeof params.source_provider === "string" ? params.source_provider : "";
const sourcePreset = typeof params.source_preset === "string" ? params.source_preset : "";
const mediaType = typeof params.media_type === "string" ? params.media_type : "";
const isTraktPreset = sourceProvider === "trakt";
const isAutoBackedTraktPreset =
isTraktPreset && (sourcePreset === "trending" || sourcePreset === "popular");
const collectionOptions = isTraktPreset
? collections.filter((collection) => {
if (collection.source !== "library" || collection.collection_type !== "trakt") {
return false;
}
const sourceConfig = collection.source_config;
if (!sourceConfig || typeof sourceConfig !== "object" || Array.isArray(sourceConfig)) {
return false;
}
return sourceConfig.preset === sourcePreset && sourceConfig.media_type === mediaType;
})
: collections;
if (isAutoBackedTraktPreset && !value) {
return (
<p className="text-xs text-white/50">
A synced Trakt {sourcePreset} {mediaType === "tv" ? "shows" : "movies"} collection will be
created automatically.
</p>
);
}
return (
<div className="space-y-1">
<span className="block text-xs text-white/70">Collection</span>
<CollectionSearchableSelect
options={collectionOptions}
value={value}
onChange={(next) => {
// Pick the right param key based on the chosen collection's source.
// Clearing the selection wipes both fields.
if (!next) {
onChange({ ...params, library_collection_id: "", user_collection_id: "" });
return;
}
const picked = collectionOptions.find((c) => c.id === next);
if (picked?.source === "user") {
onChange({ ...params, library_collection_id: "", user_collection_id: next });
} else {
onChange({ ...params, library_collection_id: next, user_collection_id: "" });
}
}}
disabled={isLoading}
isLoading={isLoading}
/>
{isTraktPreset && !isLoading && collectionOptions.length === 0 ? (
<p className="text-xs text-amber-300">
No synced Trakt {sourcePreset} {mediaType === "tv" ? "shows" : "movies"} collection was
found. Create and sync one from Admin Collections first.
</p>
) : null}
</div>
);
}