Files
silo-server/web/src/components/RecipeGallery/RecipeConfigDrawer.tsx
T
11704a1701 feat(sections): fix broken home section templates and add six new ones (#332)
* feat(sections): fix broken home section templates and add six new ones

Fixes templates that silently produced nothing:
- award_winners: hide from gallery (resolver is a stub until award data
  exists); saved sections keep resolving
- seasonal_themed: christmas/st_patricks/thanksgiving get an interim
  title-keyword resolver, and multi-theme selection skips themes without
  an executable query so a data-less theme can no longer black out the
  section during its own window (previously killed the section all of
  December)
- taste_match: empty genre now auto-picks the profile's strongest taste
  cluster (fallback: server top genre); the default preset was permanently
  empty
- because_you_watched: honor the recipe's anchor_item_id key (fetcher only
  read legacy source_item_id, so pinning an anchor did nothing)
- editorial_spotlight: reject subject_type=franchise (validated but could
  never resolve); fix drawer misrepresenting pinned presets as auto-rotate
- admin_curated_list: add a catalog-search item picker so Editor's Picks
  is actually addable; block saving an empty list; hide admin_only recipes
  from profile-facing galleries
- discovery fetchers (hidden_gems, forgotten_favorites,
  critically_acclaimed): honor single/multi library scope, intersected
  with viewer access; implement hidden_gems max_play_count

New templates: returning_shows (new season of shows you've watched),
genre_roulette (rotating top-genre spotlight with title override),
anniversaries (milestone release anniversaries this month), short_watches
(well-rated movies under a runtime cap), family_movie_night seasonal
theme (Fri/Sat evenings), and a "New in 4K" format_showcase preset via a
new sort=recent param.

Adds a blanket test asserting every visible gallery preset's defaults
pass its own recipe validation — the gap that let taste_match and
Editor's Picks ship broken. New SQL shapes validated with EXPLAIN against
the dev database.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sections): address PR #332 review findings

Codex review:
- returning_shows: the new-season file check now applies the effective
  library scope (section scope ∩ viewer-allowed, minus disabled) to
  media_files.media_folder_id, so an episode file that only exists in an
  out-of-scope folder can no longer surface the series
- buildLibraryScope: replaced the media_item_libraries row join with
  EXISTS / NOT EXISTS semi-joins. An item in several in-scope libraries
  now yields exactly one row in the non-GROUP BY rails (short_watches,
  anniversaries, seasonal keyword, format_showcase, new_to_library, ...),
  and the disabled-library check is item-level, closing the join-row leak
  where membership in an allowed library masked membership in a disabled
  one. Deny-only mode keeps the positive-membership guard, mirroring
  catalog's appendDiscoveryLibraryScope.

CodeRabbit review:
- recommendations reader: a taste cluster whose cached items are entirely
  filtered out now falls through to the next cluster / global fallback
  instead of returning an empty row
- genre_roulette: multi-library scopes get distinct rotation seeds
- returning_shows: reject negative lookback_days at validation
- shared oneOf() enum validator replaces per-recipe switch duplication
- SeasonalTitleOverride usable-filter contract covered by a direct test
- web NumberParamField: integer-only guard + step=1 (backend fields are
  Go ints; fractional values failed unmarshalling at save)
- curated list picker: search failures show an error instead of a
  misleading "No matches."; pre-existing item_ids hydrate display titles
  via the watch-detail endpoint instead of rendering raw ids

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 10:47:20 -04:00

204 lines
6.4 KiB
TypeScript

import { useState } from "react";
import BulkApplyDialog from "./BulkApplyDialog";
import RecipeParamFields from "./RecipeParamFields";
import type { RecipeDefinition, GalleryPreset } from "@/lib/recipes";
export interface AddPayload {
section_type: string;
title: string;
item_limit: number;
featured: boolean;
enabled: boolean;
config: Record<string, unknown>;
apply_to_all_libraries?: boolean;
library_ids?: number[];
}
interface Props {
def: RecipeDefinition;
preset: GalleryPreset;
/** Close the drawer without saving. Used by the bottom Cancel button. */
onCancel: () => void;
/**
* Return to the gallery from the header link. When omitted, the link
* falls back to onCancel (closes the drawer entirely).
*/
onBackToGallery?: () => void;
onAdd: (payload: AddPayload) => void | Promise<void>;
showBulkApply?: boolean;
showEnabled?: boolean;
}
export default function RecipeConfigDrawer({
def,
preset,
onCancel,
onBackToGallery,
onAdd,
showBulkApply = true,
showEnabled = true,
}: Props) {
const [title, setTitle] = useState(preset.display_name);
const [params, setParams] = useState<Record<string, unknown>>({ ...preset.default_params });
const [limit, setLimit] = useState<number>(20);
const [featured, setFeatured] = useState(false);
const [enabled, setEnabled] = useState(true);
const [applyAll, setApplyAll] = useState(false);
const [bulkOpen, setBulkOpen] = useState(false);
const libraryCollectionID =
typeof params.library_collection_id === "string" ? params.library_collection_id : "";
const userCollectionID =
typeof params.user_collection_id === "string" ? params.user_collection_id : "";
const collectionID = libraryCollectionID || userCollectionID;
const isAutoBackedTraktPreset =
def.type === "collection" &&
params.source_provider === "trakt" &&
(params.source_preset === "trending" || params.source_preset === "popular");
const collectionMissing =
def.type === "collection" && collectionID.trim() === "" && !isAutoBackedTraktPreset;
const curatedListEmpty =
def.type === "admin_curated_list" &&
(!Array.isArray(params.item_ids) || params.item_ids.length === 0);
const handleAdd = () => {
if (collectionMissing || curatedListEmpty) {
return;
}
const payload = {
section_type: def.type,
title,
item_limit: limit,
featured,
enabled,
config: params,
apply_to_all_libraries: false,
};
if (showBulkApply && applyAll) {
setBulkOpen(true);
return;
}
void Promise.resolve(onAdd(payload)).catch(() => {
// The owner reports the failure and keeps the drawer mounted for retry.
});
};
return (
<div className="max-w-[540px] rounded-xl border border-white/10 bg-zinc-900 p-6">
<div className="flex items-center justify-between border-b border-white/10 pb-3">
<h3 className="text-base font-semibold">
{preset.icon} {preset.display_name}
</h3>
<button
type="button"
onClick={onBackToGallery ?? onCancel}
className="text-xs text-white/60"
>
← Back to gallery
</button>
</div>
<div className="mt-4 rounded border-l-2 border-indigo-500 bg-indigo-500/10 px-3 py-2 text-sm">
{preset.description_long ?? preset.description_short}
</div>
<div className="mt-4">
<label htmlFor="rcd-title" className="mb-1 block text-xs text-white/70">
Title
</label>
<input
id="rcd-title"
className="w-full rounded border border-white/15 bg-white/5 px-3 py-2 text-sm"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
<div className="mt-4">
<RecipeParamFields def={def} params={params} onChange={setParams} />
</div>
{collectionMissing ? (
<p className="mt-2 text-xs text-amber-300">
Choose a synced collection before adding this section.
</p>
) : null}
{curatedListEmpty ? (
<p className="mt-2 text-xs text-amber-300">Add at least one title to the curated list.</p>
) : null}
<div className="mt-4">
<label className="mb-1 block text-xs text-white/70">Item limit</label>
<input
type="number"
min={1}
max={100}
className="w-full rounded border border-white/15 bg-white/5 px-3 py-2 text-sm"
value={limit}
onChange={(e) => setLimit(Number(e.target.value) || 20)}
/>
</div>
<label className="mt-3 flex items-center justify-between text-sm">
<span>Show as featured hero</span>
<input type="checkbox" checked={featured} onChange={(e) => setFeatured(e.target.checked)} />
</label>
{showBulkApply ? (
<label className="mt-2 flex items-center justify-between text-sm">
<span>Apply to all libraries</span>
<input
type="checkbox"
checked={applyAll}
onChange={(e) => setApplyAll(e.target.checked)}
/>
</label>
) : null}
{showEnabled ? (
<label className="mt-2 flex items-center justify-between text-sm">
<span>Enabled</span>
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
</label>
) : null}
<div className="mt-5 flex justify-end gap-2 border-t border-white/10 pt-4">
<button
type="button"
onClick={onCancel}
className="rounded border border-white/15 px-3 py-1 text-sm"
>
Cancel
</button>
<button
type="button"
onClick={handleAdd}
disabled={collectionMissing || curatedListEmpty}
className="rounded bg-indigo-600 px-3 py-1 text-sm text-white disabled:cursor-not-allowed disabled:opacity-50"
>
Add section
</button>
</div>
{showBulkApply ? (
<BulkApplyDialog
open={bulkOpen}
onClose={() => setBulkOpen(false)}
onConfirm={(libraryIDs) =>
onAdd({
section_type: def.type,
title,
item_limit: limit,
featured,
enabled,
config: params,
apply_to_all_libraries: true,
library_ids: libraryIDs,
})
}
/>
) : null}
</div>
);
}