Files
silo-server/web/src/components/RecipeGallery/RecipeParamFields.tsx
T
QuickandClaude Fable 5 69dde62fcd feat(sections): configurable sort for watchlist/favorites sections
Watchlist and favorites sections keep their stored list order by default
(provider-synced positions first, then newest-added). An optional
sort/order config now supports title, release date, IMDb rating, and
date-added-to-list ordering, applied consistently on the home rail and
the catalog "see all" page. added_at is resolved from the list entries
via a shared OrderPersonalListIDs helper since it is distinct from the
catalog's library added_at. The section editor gains a Sort dropdown.

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

400 lines
16 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;
}
// Sort choices for watchlist/favorites sections. The empty value keeps the
// list's stored order (provider sync order, then newest-added first).
const PERSONAL_LIST_SORT_OPTIONS = [
{ value: "", label: "List order (default)" },
{ value: "added_at:desc", label: "Date added (newest first)" },
{ value: "added_at:asc", label: "Date added (oldest first)" },
{ value: "title:asc", label: "Title (A–Z)" },
{ value: "title:desc", label: "Title (Z–A)" },
{ value: "release_date:desc", label: "Release date (newest first)" },
{ value: "release_date:asc", label: "Release date (oldest first)" },
{ value: "rating_imdb:desc", label: "IMDb rating (highest first)" },
];
// PersonalListFilterFields edits the optional filter_type / filter_library_ids
// filters and sort for watchlist and favorites sections, e.g. a "Movies
// watchlist" rail sorted by release date.
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")
: [];
const sortField = typeof params.sort === "string" ? params.sort : "";
const sortOrder =
typeof params.order === "string" && params.order
? params.order
: sortField === "title"
? "asc" // mirror the backend's per-field default order
: "desc";
const sortValue = sortField ? `${sortField}:${sortOrder}` : "";
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>
<label className="block md:col-span-2">
<span className="mb-1 block text-xs text-white/70">Sort</span>
<select
value={PERSONAL_LIST_SORT_OPTIONS.some((o) => o.value === sortValue) ? sortValue : ""}
onChange={(e) => {
const [sort, order] = e.target.value.split(":");
onChange({ ...params, sort: sort || undefined, order: order || undefined });
}}
className="w-full rounded border border-white/15 bg-white/5 px-3 py-2 text-sm"
>
{PERSONAL_LIST_SORT_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</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>
);
}